diff --git a/changelog.d/session-tree-ui.added.md b/changelog.d/session-tree-ui.added.md new file mode 100644 index 000000000..7bb625812 --- /dev/null +++ b/changelog.d/session-tree-ui.added.md @@ -0,0 +1 @@ +Session titles now describe their first task, and the session picker groups sub-tasks under their parent session. diff --git a/n00n-lua/src/api/session.rs b/n00n-lua/src/api/session.rs index 2937faaf3..436dc5f6f 100644 --- a/n00n-lua/src/api/session.rs +++ b/n00n-lua/src/api/session.rs @@ -38,7 +38,8 @@ async fn roundtrip( /// Lists sessions stored for the current project. Answered from a /// background scan, so a slow disk never blocks the UI. /// -/// @return (table|nil, string|nil) Array of `{id, title, updated_at}`, or nil and an error. +/// @return (table|nil, string|nil) Array of `{id, title, display_title, kind, +/// parent_id, updated_at, cwd, model}`, or nil and an error. /// @example /// local stored, err = n00n.session.list() #[lua_fn] @@ -114,7 +115,8 @@ async fn delete( /// Starts a new session in the current project. /// /// @param opts table? Optional fields: prompt (string) first user message -/// to submit right away; focus (boolean) switch the UI to the new session. +/// to submit right away; focus (boolean) switch the UI to the new session; +/// parent_id (string?) session that spawned this session. /// @return (string|nil, string|nil) New session id, or nil and an error. /// @example /// local id, err = n00n.session.new({ prompt = "fix the tests", focus = true }) @@ -124,14 +126,24 @@ async fn new( #[ctx] tx: Option>, opts: Option, ) -> LuaResult { - let (prompt, focus) = match opts { + let (prompt, focus, parent_id) = match opts { Some(opts) => ( opts.get("prompt")?, opts.get("focus").unwrap_or_else(|_| false), + opts.get("parent_id")?, ), - None => (None, false), + None => (None, false, None), }; - roundtrip(lua, tx, SessionRequest::New { prompt, focus }).await + roundtrip( + lua, + tx, + SessionRequest::New { + prompt, + focus, + parent_id, + }, + ) + .await } /// Sends {text} as a regular user prompt to a live session. The text is diff --git a/n00n-lua/src/api/util/command.rs b/n00n-lua/src/api/util/command.rs index 2606369b9..aad1f4446 100644 --- a/n00n-lua/src/api/util/command.rs +++ b/n00n-lua/src/api/util/command.rs @@ -406,14 +406,32 @@ pub enum WinCommand { pub enum SessionRequest { List, Live, - Status { id: String }, + Status { + id: String, + }, Current, - New { prompt: Option, focus: bool }, - Prompt { id: Option, text: String }, - Cancel { id: String }, - Focus { id: String }, - Delete { id: String }, - SetTitle { id: String, title: String }, + New { + prompt: Option, + focus: bool, + parent_id: Option, + }, + Prompt { + id: Option, + text: String, + }, + Cancel { + id: String, + }, + Focus { + id: String, + }, + Delete { + id: String, + }, + SetTitle { + id: String, + title: String, + }, } pub type SessionReply = Result; diff --git a/n00n-lua/tests/plugin_host.rs b/n00n-lua/tests/plugin_host.rs index 2f803d5c0..82e7e86bb 100644 --- a/n00n-lua/tests/plugin_host.rs +++ b/n00n-lua/tests/plugin_host.rs @@ -2632,6 +2632,103 @@ fn sessions_plugin_registers_commands() { assert!(names.contains(&"/rename"), "missing /rename in {names:?}"); } +#[test] +fn sessions_plugin_declares_render_before_callbacks() { + let source = include_str!("../../plugins/sessions/init.lua"); + let render_decl = source + .find("\nlocal render\n") + .expect("sessions plugin must forward-declare local render"); + let set_sel = source + .find("local function set_sel") + .expect("sessions plugin must define set_sel"); + assert!( + render_decl < set_sel, + "local render must precede set_sel so navigation callbacks capture it as an upvalue, not a nil global" + ); +} + +#[test] +fn sessions_plugin_rename_persists_kind_prefix_in_stored_title() { + let source = include_str!("../../plugins/sessions/init.lua"); + let commit = source + .find("local function commit_rename()") + .expect("commit_rename"); + let body = &source[commit..]; + let stored_assign = body + .find("board.stored[si].title") + .expect("commit_rename updates board.stored title"); + let snippet = &body[stored_assign..stored_assign + 80]; + assert!( + snippet.contains("stored_title"), + "in-memory stored title must keep the kind prefix via stored_title, got: {snippet}" + ); +} + +#[test] +fn sessions_picker_groups_more_than_twenty_children() { + let registry = fresh_registry(); + let host = PluginHost::new(Arc::clone(®istry)).unwrap(); + let mut source = include_str!("../../plugins/sessions/init.lua").to_string(); + source.push_str( + r#" +n00n.api.register_tool({ + name = "sessions_group_probe", + description = "test", + schema = { type = "object", properties = {} }, + audiences = { "main" }, + handler = function() + local parent = { id = "parent", children = {} } + local all_nodes = { parent } + local rank = { parent = 0 } + for i = 1, 21 do + local child = { id = "child-" .. i, parent_id = parent.id, updated_at = 100 - i, children = {} } + rank[child.id] = i + parent.children[i] = child + all_nodes[#all_nodes + 1] = child + end + local expanded_state = { ["group:parent:1"] = true } + group_node(parent, all_nodes, rank, expanded_state) + local first_child = parent.children[1].children[1] + local original_parent = first_child.parent_id + local group_id = first_child.group_id + group_node(parent, all_nodes, rank, expanded_state) + return n00n.json.encode({ + buckets = #parent.children, + first_id = parent.children[1].id, + first_expanded = parent.children[1].expanded, + first_children = #parent.children[1].children, + second_children = #parent.children[2].children, + child_parent = original_parent, + child_group = group_id, + child_parent_after_refresh = parent.children[1].children[1].parent_id, + child_group_after_refresh = parent.children[1].children[1].group_id, + + }) + end, +}) +"#, + ); + host.load_source("sessions_group_test", &source).unwrap(); + + let output = exec_tool(®istry, "sessions_group_probe", serde_json::json!({})).unwrap(); + let grouped: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(grouped["buckets"], serde_json::json!(2)); + assert_eq!(grouped["first_id"], serde_json::json!("group:parent:1")); + assert_eq!(grouped["first_expanded"], serde_json::json!(true)); + assert_eq!(grouped["first_children"], serde_json::json!(20)); + assert_eq!(grouped["second_children"], serde_json::json!(1)); + assert_eq!(grouped["child_parent"], serde_json::json!("parent")); + assert_eq!(grouped["child_group"], serde_json::json!("group:parent:1")); + assert_eq!( + grouped["child_parent_after_refresh"], + serde_json::json!("parent") + ); + assert_eq!( + grouped["child_group_after_refresh"], + serde_json::json!("group:parent:1") + ); +} + #[test] fn job_callback_finishes_after_handler_returns_nil() { let reg = fresh_registry(); diff --git a/n00n-storage/src/sessions.rs b/n00n-storage/src/sessions.rs index d3553a583..f597becbf 100644 --- a/n00n-storage/src/sessions.rs +++ b/n00n-storage/src/sessions.rs @@ -34,9 +34,16 @@ const MAX_INCREMENTAL_FRAMES: u64 = 16_384; const TRANSCRIPT_RECORD_TYPE: &str = "transcript"; pub const SESSIONS_DIR: &str = "sessions"; const CWD_INDEX_FILE: &str = "cwd_latest.json"; -const SCAN_CACHE_FILE: &str = "scan_cache_v2.json"; +const SCAN_CACHE_FILE: &str = "scan_cache_v3.json"; +const SCAN_CACHE_FILE_V2: &str = "scan_cache_v2.json"; const DEFAULT_TITLE: &str = "New session"; const MAX_TITLE_LEN: usize = 60; +const MAX_SNIPPET_BYTES: usize = 256; +const MAX_FIRST_MESSAGE_LINE_BYTES: usize = 64 * 1024; +const MAX_FIRST_MESSAGE_TEXT_BYTES: usize = 1024; +const MAX_FIRST_MESSAGE_BYTES: usize = 256 * 1024; +const META_RECORD_PREFIX: &str = r#"{"t":"meta""#; +const MSG_RECORD_PREFIX: &str = r#"{"t":"msg""#; const OPENAI_RESPONSE_CHAIN_SUFFIX: &str = "openai-response.json"; const OPENAI_RESPONSE_CHAIN_LOCK_SUFFIX: &str = "openai-response.lock"; const OPENAI_RESPONSE_CHAIN_FILE_MODE: u32 = 0o600; @@ -138,6 +145,8 @@ pub struct StoredQueuedMessage { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct SessionMeta { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, #[serde(default)] pub mode: Option, #[serde(default)] @@ -234,6 +243,12 @@ pub struct Session { pub struct SessionSummary { pub id: n00nId, pub title: String, + #[serde(default)] + pub display_title: String, + #[serde(default)] + pub kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, pub updated_at: u64, #[serde(default)] pub cwd: String, @@ -448,12 +463,22 @@ pub fn generate_title(messages: &[M]) -> String { let Some(text) = first_user_text.map(str::trim).filter(|t| !t.is_empty()) else { return DEFAULT_TITLE.into(); }; - let text = normalize_title(text); + truncate_label(text) +} +const SUBTASK_TITLE_PREFIXES: &[(&str, &str)] = &[ + ("team:", "team"), + ("workflow:", "workflow"), + ("task:", "task"), +]; + +const SUBTASK_JSON_KEYS: &[&str] = &["goal", "description", "prompt", "name", "title", "input"]; + +fn truncate_label(text: &str) -> String { + let text = normalize_title(text); if text.len() <= MAX_TITLE_LEN { return text; } - let boundary = text.floor_char_boundary(MAX_TITLE_LEN); let truncated = &text[..boundary]; match truncated.rfind(' ') { @@ -462,6 +487,113 @@ pub fn generate_title(messages: &[M]) -> String { } } +fn strip_tool_directive(text: &str) -> Option<(&'static str, &str)> { + let text = text.strip_prefix("Use the ")?; + let end = text.find(" tool now")?; + let requested_kind = &text[..end]; + let (_, kind) = SUBTASK_TITLE_PREFIXES + .iter() + .find(|(_, kind)| *kind == requested_kind)?; + let after = &text[end + " tool now".len()..]; + if after + .chars() + .next() + .is_some_and(|c| !c.is_whitespace() && !c.is_ascii_punctuation()) + { + return None; + } + let body = after.split_once("\n\n").map_or(after, |(_, body)| body); + Some((kind, body.trim_start())) +} + +fn snippet_from_json(value: &serde_json::Value) -> Option { + fn find_string(value: &serde_json::Value) -> Option { + value + .as_str() + .map(std::string::ToString::to_string) + .filter(|s| !s.is_empty()) + .map(|s| cap_text(&s, MAX_SNIPPET_BYTES)) + } + + for key in SUBTASK_JSON_KEYS { + if let Some(v) = value.get(*key) { + if let Some(s) = find_string(v) { + return Some(s); + } + for key2 in SUBTASK_JSON_KEYS { + if let Some(nested) = v.get(*key2).and_then(find_string) { + return Some(nested); + } + } + } + } + + value + .as_object() + .and_then(|obj| obj.values().find_map(find_string)) +} + +fn cap_text(text: &str, max_bytes: usize) -> String { + let cap = text.len().min(max_bytes); + let boundary = text.floor_char_boundary(cap); + text[..boundary].to_string() +} + +fn snippet_from_text(text: &str) -> Option { + let trimmed = text.trim_start(); + if (trimmed.starts_with('{') || trimmed.starts_with('[')) + && let Ok(value) = serde_json::from_str::(trimmed) + && let Some(snippet) = snippet_from_json(&value) + { + return Some(cap_text(&snippet, MAX_SNIPPET_BYTES)); + } + let first = trimmed + .split_once('\n') + .map_or(trimmed, |(line, _)| line) + .trim(); + if first.is_empty() { + None + } else { + Some(cap_text(first, MAX_SNIPPET_BYTES)) + } +} + +fn classify_and_display(title: &str, first_message: Option<&str>) -> (String, String) { + let title_norm = normalize_title(title); + let title_norm_lower = title_norm.to_ascii_lowercase(); + let message = first_message.map(str::trim).filter(|t| !t.is_empty()); + + for (prefix, kind) in SUBTASK_TITLE_PREFIXES { + if title_norm_lower.starts_with(prefix) { + let rest = title_norm[prefix.len()..].trim(); + let display = if rest.is_empty() { + message + .and_then(snippet_from_text) + .unwrap_or_else(|| title_norm.clone()) + } else { + rest.to_string() + }; + return (truncate_label(&display), kind.to_string()); + } + } + + if let Some(text) = message + && let Some((kind, body)) = strip_tool_directive(text) + { + let display = snippet_from_text(body).unwrap_or_else(|| body.to_string()); + return (truncate_label(&display), kind.to_string()); + } + + let display = if title_norm == DEFAULT_TITLE || title_norm.starts_with("Use the ") { + message + .and_then(snippet_from_text) + .unwrap_or_else(|| title_norm.clone()) + } else { + title_norm + }; + (truncate_label(&display), "main".to_string()) +} + // -- JSONL record types -- #[derive(Serialize, Deserialize)] @@ -477,6 +609,8 @@ enum LogRecord { #[serde(default, skip_serializing_if = "Option::is_none")] title: Option, created_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_id: Option, }, #[serde(rename = "msg")] Msg { d: M }, @@ -905,6 +1039,7 @@ where cwd: session.cwd.clone(), title: Some(session.title.clone()), created_at: session.created_at, + parent_id: session.meta.parent_id, }, )?; for msg in &session.messages { @@ -1098,6 +1233,7 @@ where cwd: h_cwd, title: h_title, created_at: h_created, + parent_id: h_parent, } => { if v != LOG_FORMAT_VERSION { return Err(SessionError::VersionMismatch { @@ -1109,6 +1245,7 @@ where builder.model = h_model; builder.cwd = h_cwd; builder.created_at = h_created; + builder.meta.parent_id = h_parent; if let Some(t) = h_title { builder.title = t; } @@ -1403,6 +1540,10 @@ struct ZstHeader { cwd: String, #[serde(default)] title: Option, + #[serde(default)] + created_at: u64, + #[serde(default)] + parent_id: Option, } #[derive(Deserialize)] @@ -1412,8 +1553,6 @@ struct MetaScan { updated_at: u64, } -const META_RECORD_PREFIX: &str = r#"{"t":"meta""#; - #[derive(Debug, Clone, Serialize, Deserialize)] struct ScannedHeader { id: n00nId, @@ -1422,6 +1561,12 @@ struct ScannedHeader { updated_at: u64, #[serde(default)] model: String, + #[serde(default)] + created_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + first_message: Option, + #[serde(default)] + parent_id: Option, } /// Cached scan result for one session file, keyed by file name and validated @@ -1454,11 +1599,14 @@ fn file_signature(path: &Path) -> Option<(u64, u64)> { Some((meta.len(), mtime_ms)) } -fn scan_headers(cwd: &str, dir: &Path) -> Result, StorageError> { +fn scan_headers(cwd: &str, dir: &Path) -> Result, StorageError> +where + M: TitleSource + DeserializeOwned + Default, +{ let mut cache = load_scan_cache(dir); let mut fresh = ScanCache::new(); let mut dirty = false; - let mut summaries = Vec::new(); + let mut with_created: Vec<(u64, SessionSummary)> = Vec::new(); for path in session_entries(dir)? { let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; @@ -1470,7 +1618,7 @@ fn scan_headers(cwd: &str, dir: &Path) -> Result, StorageErr Some(e) if e.size == size && e.mtime_ms == mtime_ms => e, _ => { dirty = true; - let header = scan_zst_header(&path); + let header = scan_zst_header::(&path); ScanCacheEntry { size, mtime_ms, @@ -1481,13 +1629,25 @@ fn scan_headers(cwd: &str, dir: &Path) -> Result, StorageErr if let Some(h) = &entry.header && h.cwd == cwd { - summaries.push(SessionSummary { - id: h.id, - title: normalize_title(&h.title), - updated_at: h.updated_at, - cwd: h.cwd.clone(), - model: h.model.clone(), - }); + let (display_title, kind) = classify_and_display(&h.title, h.first_message.as_deref()); + let created_at = if h.created_at != 0 { + h.created_at + } else { + h.updated_at + }; + with_created.push(( + created_at, + SessionSummary { + id: h.id, + title: normalize_title(&h.title), + display_title, + kind, + parent_id: h.parent_id, + updated_at: h.updated_at, + cwd: h.cwd.clone(), + model: h.model.clone(), + }, + )); } fresh.insert(name.to_owned(), entry); } @@ -1498,7 +1658,27 @@ fn scan_headers(cwd: &str, dir: &Path) -> Result, StorageErr { warn!(error = %e, "failed to write session scan cache"); } - Ok(summaries) + if let Err(error) = fs::remove_file(dir.join(SCAN_CACHE_FILE_V2)) + && error.kind() != ErrorKind::NotFound + { + warn!(error = %error, "failed to remove stale v2 session scan cache"); + } + + let mut order: Vec = (0..with_created.len()).collect(); + order.sort_unstable_by_key(|i| (with_created[*i].0, *with_created[*i].1.id.as_bytes())); + let mut last_main: Option = None; + for i in order { + let summary = &mut with_created[i].1; + if summary.kind == "main" { + last_main = Some(summary.id); + } else if summary.parent_id.is_none() + && let Some(parent) = last_main + { + summary.parent_id = Some(parent); + } + } + + Ok(with_created.into_iter().map(|(_, s)| s).collect()) } const ZSTD_MAGIC: &[u8] = &[0x28, 0xb5, 0x2f, 0xfd]; @@ -1531,7 +1711,10 @@ fn try_decode_header_at(path: &Path, offset: u64) -> Option { } } -fn try_decode_last_meta_at(path: &Path, offset: u64) -> Option<(String, u64)> { +fn try_decode_last_meta_at(path: &Path, offset: u64) -> Option<(String, u64, Option)> +where + M: TitleSource + DeserializeOwned + Default, +{ let file = File::open(path).ok()?; let mut file = file; file.seek(SeekFrom::Start(offset)).ok()?; @@ -1540,6 +1723,7 @@ fn try_decode_last_meta_at(path: &Path, offset: u64) -> Option<(String, u64)> { let mut line = String::new(); let mut title = String::new(); let mut updated_at = 0u64; + let mut first_message = None; loop { line.clear(); match reader.read_line(&mut line) { @@ -1560,14 +1744,60 @@ fn try_decode_last_meta_at(path: &Path, offset: u64) -> Option<(String, u64)> { title = t; updated_at = u; } + if offset == 0 + && first_message.is_none() + && trimmed.len() <= MAX_FIRST_MESSAGE_LINE_BYTES + && trimmed.starts_with(MSG_RECORD_PREFIX) + && let Ok(LogRecord::::Msg { d }) = + serde_json::from_str(trimmed) + && let Some(text) = d.first_user_text().map(str::trim).filter(|t| !t.is_empty()) + { + first_message = Some(cap_text(text, MAX_FIRST_MESSAGE_TEXT_BYTES)); + } } if updated_at == 0 && title.is_empty() { return None; } - Some((title, updated_at)) + Some((title, updated_at, first_message)) +} + +fn try_decode_first_message_at(path: &Path, offset: u64) -> Option +where + M: TitleSource + DeserializeOwned + Default, +{ + let file = File::open(path).ok()?; + let mut file = file; + file.seek(SeekFrom::Start(offset)).ok()?; + let decoder = Decoder::new(file).ok()?; + let limited = decoder.take(MAX_FIRST_MESSAGE_BYTES as u64); + let mut reader = BufReader::new(limited); + let mut line = String::new(); + loop { + line.clear(); + match reader.read_line(&mut line) { + Ok(0) | Err(_) => return None, + Ok(_) => {} + } + let trimmed = line.trim_end_matches(['\r', '\n']); + if trimmed.is_empty() + || !trimmed.starts_with(MSG_RECORD_PREFIX) + || trimmed.len() > MAX_FIRST_MESSAGE_LINE_BYTES + { + continue; + } + if let Ok(LogRecord::::Msg { d }) = + serde_json::from_str(trimmed) + && let Some(text) = d.first_user_text().map(str::trim).filter(|t| !t.is_empty()) + { + return Some(cap_text(text, MAX_FIRST_MESSAGE_TEXT_BYTES)); + } + } } -fn find_last_frame_meta(path: &Path) -> Option<(String, u64)> { +fn find_last_frame_meta(path: &Path) -> Option<(String, u64, Option)> +where + M: TitleSource + DeserializeOwned + Default, +{ let file_len = fs::metadata(path).ok()?.len(); if file_len < ZSTD_MAGIC.len() as u64 { return None; @@ -1594,13 +1824,13 @@ fn find_last_frame_meta(path: &Path) -> Option<(String, u64)> { for &pos in positions.iter().rev() { let offset = start + pos as u64; - if let Some(meta) = try_decode_last_meta_at(path, offset) { + if let Some(meta) = try_decode_last_meta_at::(path, offset) { return Some(meta); } } if searched >= MAX_LAST_FRAME_SEARCH_BYTES { - return try_decode_last_meta_at(path, 0); + return try_decode_last_meta_at::(path, 0); } if start == 0 { @@ -1612,30 +1842,48 @@ fn find_last_frame_meta(path: &Path) -> Option<(String, u64)> { None } -fn scan_zst_header(path: &Path) -> Option { +fn scan_zst_header(path: &Path) -> Option +where + M: TitleSource + DeserializeOwned + Default, +{ let header = try_decode_header_at(path, 0)?; if header.v != LOG_FORMAT_VERSION { return None; } - let (meta_title, updated_at) = find_last_frame_meta(path).unwrap_or_else(|| { - let mut title = String::new(); - let mut updated_at = 0u64; - let _ = visit_zstd_lines(path, |line| { - if !line.is_empty() - && line.starts_with(META_RECORD_PREFIX) - && let Ok(MetaScan { - title: t, - updated_at: u, - }) = serde_json::from_str(line) - { - title = t; - updated_at = u; - } - Ok(()) + let (meta_title, updated_at, first_message) = + find_last_frame_meta::(path).unwrap_or_else(|| { + let mut title = String::new(); + let mut updated_at = 0u64; + let mut first_message = None; + let _ = visit_zstd_lines(path, |line| { + if !line.is_empty() { + if line.starts_with(META_RECORD_PREFIX) + && let Ok(MetaScan { + title: t, + updated_at: u, + }) = serde_json::from_str(line) + { + title = t; + updated_at = u; + } + if first_message.is_none() + && line.starts_with(MSG_RECORD_PREFIX) + && line.len() <= MAX_FIRST_MESSAGE_LINE_BYTES + && let Ok(LogRecord::::Msg { d }) = + serde_json::from_str(line) + && let Some(text) = + d.first_user_text().map(str::trim).filter(|t| !t.is_empty()) + { + first_message = Some(cap_text(text, MAX_FIRST_MESSAGE_TEXT_BYTES)); + } + } + Ok(()) + }); + (title, updated_at, first_message) }); - (title, updated_at) - }); + + let first_message = first_message.or_else(|| try_decode_first_message_at::(path, 0)); Some(ScannedHeader { id: header.id, @@ -1650,6 +1898,9 @@ fn scan_zst_header(path: &Path) -> Option { }, updated_at, model: header.model, + created_at: header.created_at, + first_message, + parent_id: header.parent_id, }) } @@ -1801,8 +2052,8 @@ where /// # Errors /// Returns `SessionError` if the scan fails. pub fn list_in(cwd: &str, dir: &Path) -> Result, SessionError> { - let mut summaries = scan_headers(cwd, dir)?; - summaries.sort_unstable_by_key(|s| Reverse(s.updated_at)); + let mut summaries = scan_headers::(cwd, dir)?; + summaries.sort_unstable_by_key(|s| (Reverse(s.updated_at), *s.id.as_bytes())); Ok(summaries) } @@ -1816,7 +2067,7 @@ where /// # Errors /// Returns `SessionError` if the scan or load fails. pub fn latest_in(cwd: &str, dir: &Path) -> Result, SessionError> { - let latest = scan_headers(cwd, dir)? + let latest = scan_headers::(cwd, dir)? .into_iter() .max_by_key(|s| s.updated_at); match latest { @@ -1867,8 +2118,8 @@ mod tests { use super::ThinkingParseError; use super::{ CWD_INDEX_FILE, DEFAULT_TITLE, LOG_FORMAT_VERSION, LogRecord, MAX_TITLE_LEN, - SESSION_VERSION, StoredSubagent, append_record, encode_frame, generate_title, jsonl_path, - load_cwd_index, now_epoch, update_cwd_index, + SESSION_VERSION, StoredSubagent, append_record, classify_and_display, encode_frame, + generate_title, jsonl_path, load_cwd_index, now_epoch, update_cwd_index, }; use super::{ OPENAI_RESPONSE_CHAIN_TTL_SECONDS, SESSIONS_DIR, StoredOpenAiResponseChain, @@ -2355,6 +2606,7 @@ mod tests { cwd: session.cwd.clone(), title: Some(session.title.clone()), created_at: session.created_at, + parent_id: None, }, ) .unwrap(); @@ -2398,6 +2650,7 @@ mod tests { cwd: session.cwd.clone(), title: Some(session.title.clone()), created_at: session.created_at, + parent_id: None, }, ) .unwrap(); @@ -3030,6 +3283,67 @@ mod tests { assert_eq!(generate_title(&messages), expected); } + #[test_case( + "Use the team tool now. Do not only describe this request.\n\n{\"goal\":\"fix grouping\"}", + "fix grouping", + "team" + ; "recognized_directive" + )] + #[test_case( + "Use the bash tool now.\n\n{\"prompt\":\"not a sub-task\"}", + "Use the bash tool now.", + "main" + ; "unrecognized_tool" + )] + #[test_case( + "Use the team tool nowish.\n\n{\"goal\":\"not a directive\"}", + "Use the team tool nowish.", + "main" + ; "malformed_directive" + )] + fn display_title_only_classifies_known_tool_directives( + message: &str, + expected_title: &str, + expected_kind: &str, + ) { + let (title, kind) = classify_and_display(DEFAULT_TITLE, Some(message)); + assert_eq!(title, expected_title); + assert_eq!(kind, expected_kind); + } + + #[test] + fn equal_creation_times_group_and_sort_deterministically() { + let tmp = TempDir::new().unwrap(); + let dir = tmp.path(); + let ids = [ + "00000000-0000-7000-8000-000000000001", + "00000000-0000-7000-8000-000000000002", + "00000000-0000-7000-8000-000000000003", + "00000000-0000-7000-8000-000000000004", + ] + .map(|id| id.parse::().unwrap()); + let titles = ["main one", "task: child one", "main two", "team: child two"]; + + for (id, title) in ids.into_iter().zip(titles) { + let mut session: TestSession = Session::new("m", "/project"); + session.id = id; + session.title = title.into(); + session.created_at = 100; + session.updated_at = 200; + SessionLog::create(dir, &session).unwrap(); + } + + let list = TestSession::list_in("/project", dir).unwrap(); + assert_eq!( + list.iter().map(|summary| summary.id).collect::>(), + ids + ); + assert_eq!(list[0].parent_id, None); + assert_eq!(list[1].parent_id, Some(ids[0])); + assert_eq!(list[2].parent_id, None); + assert_eq!(list[3].parent_id, Some(ids[2])); + } + #[test] fn delete_removes_file_and_cwd_index() { let tmp = TempDir::new().unwrap(); @@ -3195,6 +3509,7 @@ mod tests { model: session.model.clone(), cwd: session.cwd.clone(), created_at: session.created_at, + parent_id: None, title: Some("header title".into()), }; let mut bytes = Vec::new(); diff --git a/n00n-ui/src/event_loop.rs b/n00n-ui/src/event_loop.rs index 9ef930e1a..8065e0901 100644 --- a/n00n-ui/src/event_loop.rs +++ b/n00n-ui/src/event_loop.rs @@ -864,12 +864,27 @@ impl<'t> EventLoop<'t> { SessionRequest::Current => { let _ = reply_tx.send(Ok(json!(self.sessions[self.focused].id()))); } - SessionRequest::New { prompt, focus } => { - let session = { + SessionRequest::New { + prompt, + focus, + parent_id, + } => { + let mut session = { let slot = self.ctx.model_slot.load(); let cwd = std::env::current_dir().unwrap_or_else(|_| ".".into()); AppSession::new(&slot.model.spec(), &cwd.to_string_lossy()) }; + let parent_id = match parent_id { + Some(id) => match parse_session_id(&id) { + Ok(id) => Some(id), + Err(error) => { + let _ = reply_tx.send(Err(error)); + return; + } + }, + None => None, + }; + session.meta.parent_id = parent_id; let idx = self.push_runtime(self.ctx.spawn_runtime(session)); let id = self.sessions[idx].id(); if let Some(prompt) = prompt { diff --git a/plugins/sessions/init.lua b/plugins/sessions/init.lua index 33614e2ec..1cb1ce353 100644 --- a/plugins/sessions/init.lua +++ b/plugins/sessions/init.lua @@ -1,7 +1,8 @@ --- The /sessions picker: one flat list of every session in this directory, --- live or stored. Live ones get a colored icon, and row order is frozen --- while the picker is open so rows never jump around under the cursor --- while background agents keep working. +-- The /sessions picker: a tree of sessions in this directory. Main sessions +-- are shown by default; sub-tasks are grouped underneath them and can be +-- expanded with the Right arrow. Live sessions get a colored icon, and row +-- order is frozen while the picker is open so rows never jump around under +-- the cursor while background agents keep working. local TextInput = require("n00n.text_input") local ListPicker = require("n00n.list_picker") @@ -9,6 +10,7 @@ local ListPicker = require("n00n.list_picker") local FILTER_PREFIX = "❯ " local RENAME_PREFIX = "Rename: " local CONFIRM_HINT = " Ctrl+D again to delete" +local CANNOT_DELETE_GROUP_HINT = "Cannot delete a group" local DELETE_FOCUSED_HINT = "Cannot delete the current session" local RENAME_USAGE = "Usage: /rename " local EMPTY_HINT = " No sessions yet. Press Ctrl+N to start one." @@ -28,11 +30,16 @@ local AGE_UNITS = { { 3600, "h" }, { 60, "m" }, } +local MAX_GROUP_CHILDREN = 20 +local GROUP_PREFIX = "group:" +local GROUP_KIND = "group" local FILTER_KEYS = { { "Enter", "open" }, { "Ctrl+N", "new" }, { "Ctrl+R", "rename" }, { "Ctrl+D", "delete" }, + { "Right", "expand" }, + { "Left", "collapse" }, } local RENAME_KEYS = { { "Enter", "save" }, @@ -42,6 +49,9 @@ local RENAME_KEYS = { local board = nil local function icon_of(s) + if s.is_group then + return " ", "dim" + end if s.status == "needs_input" then return "◆ ", "warning" end @@ -57,17 +67,29 @@ local function icon_of(s) return " ", "dim" end --- Current session first, then most recently opened. local function by_recency(a, b) if a.focused ~= b.focused then return a.focused end - return (a.updated_at or 0) > (b.updated_at or 0) + local ra, rb = board.rank[a.id], board.rank[b.id] + if ra and rb then + return ra < rb + end + if ra then + return true + end + if rb then + return false + end + local a_updated, b_updated = a.updated_at or 0, b.updated_at or 0 + if a_updated ~= b_updated then + return a_updated > b_updated + end + return (a.id or "") < (b.id or "") end --- Rows keep their rank for the picker's lifetime; new ones enter above --- existing ones so nothing already on screen moves. Within one batch --- (notably the first full refresh) recency decides. +-- Assign stable ranks to new nodes so they keep their position while the +-- picker is open, even if their `updated_at` keeps changing. local function assign_ranks(fresh) table.sort(fresh, by_recency) local base = board.min_rank - #fresh @@ -77,22 +99,18 @@ local function assign_ranks(fresh) board.min_rank = base end -local function update_footer() - if board.rename then - board.win:set_config({ footer = RENAME_KEYS }) - return - end - local footer = {} - if board.counts.needs_input > 0 then - footer[#footer + 1] = { "◆ " .. board.counts.needs_input, "needs input" } - end - if board.counts.working > 0 then - footer[#footer + 1] = { "● " .. board.counts.working, "working" } - end - for _, f in ipairs(FILTER_KEYS) do - footer[#footer + 1] = f +local function dispw(s) + return utf8.len(s) or #s +end + +local function age(updated_at) + local secs = math.max(os.time() - (updated_at or 0), 0) + for _, u in ipairs(AGE_UNITS) do + if secs >= u[1] then + return math.floor(secs / u[1]) .. u[2] .. " ago" + end end - board.win:set_config({ footer = footer }) + return "just now" end local function filter_words() @@ -108,6 +126,11 @@ local function sel_index() return nil end +local function selected() + local idx = sel_index() + return idx and board.items[idx] or nil +end + local function find_stored(id) for i, st in ipairs(board.stored or {}) do if st.id == id then @@ -117,13 +140,130 @@ local function find_stored(id) return nil end +local function normalize_session(s, expanded_state) + s.title = s.title or "" + s.display_title = s.display_title or s.title + if s.display_title == "" then + s.display_title = "New session" + end + s.kind = s.kind or "main" + s.updated_at = s.updated_at or 0 + s.children = {} + s.expanded = expanded_state[s.id] or false + s.depth = 0 +end + +local function sort_tree(nodes) + table.sort(nodes, by_recency) + for _, n in ipairs(nodes) do + if #n.children > 0 then + sort_tree(n.children) + end + end +end + +local function build_tree(sessions) + local by_id = {} + for _, s in ipairs(sessions) do + by_id[s.id] = s + end + local roots = {} + for _, s in ipairs(sessions) do + local p = s.parent_id + if p and by_id[p] then + table.insert(by_id[p].children, s) + else + table.insert(roots, s) + end + end + sort_tree(roots) + return roots +end + +local function task_count(count) + return count .. (count == 1 and " task" or " tasks") +end + +local function group_label(children, start_idx, finish) + local count = finish - start_idx + 1 + local count_text = task_count(count) + local newest = children[start_idx].updated_at + local oldest = children[start_idx].updated_at + for i = start_idx + 1, finish do + newest = math.max(newest, children[i].updated_at) + oldest = math.min(oldest, children[i].updated_at) + end + local newest_age = age(newest) + local oldest_age = age(oldest) + if newest_age == oldest_age then + return count_text .. " · " .. newest_age + end + return count_text .. " · " .. newest_age .. " – " .. oldest_age +end + +local function make_bucket(parent, children, start_idx, finish, all_nodes, rank, expanded_state) + local bucket_id = GROUP_PREFIX .. parent.id .. ":" .. start_idx + local bucket = { + id = bucket_id, + title = "", + display_title = group_label(children, start_idx, finish), + kind = GROUP_KIND, + is_group = true, + children = {}, + parent_id = parent.id, + updated_at = children[start_idx].updated_at, + focused = false, + live = false, + status = "idle", + expanded = expanded_state[bucket_id] or false, + depth = 0, + } + rank[bucket.id] = rank[children[start_idx].id] - 0.5 + for i = start_idx, finish do + local child = children[i] + child.group_id = bucket.id + table.insert(bucket.children, child) + end + table.insert(all_nodes, bucket) + return bucket +end + +local function group_node(node, all_nodes, rank, expanded_state) + if #node.children > MAX_GROUP_CHILDREN then + local buckets = {} + for i = 1, #node.children, MAX_GROUP_CHILDREN do + local finish = math.min(i + MAX_GROUP_CHILDREN - 1, #node.children) + table.insert(buckets, make_bucket(node, node.children, i, finish, all_nodes, rank, expanded_state)) + end + node.children = buckets + end + for _, child in ipairs(node.children) do + group_node(child, all_nodes, rank, expanded_state) + end +end + +local function flatten_visible(nodes, depth, items) + for _, n in ipairs(nodes) do + n.depth = depth + table.insert(items, n) + if n.expanded and #n.children > 0 then + flatten_visible(n.children, depth + 1, items) + end + end +end + local function apply_filter() local prev_pos = sel_index() or 1 local words = filter_words() board.items = {} - for _, s in ipairs(board.all) do - if ListPicker.matches(s.title, words) then - board.items[#board.items + 1] = s + if #words == 0 then + flatten_visible(board.roots, 0, board.items) + else + for _, n in ipairs(board.nodes) do + n.depth = 0 + if not n.is_group and (ListPicker.matches(n.display_title, words) or ListPicker.matches(n.title, words)) then + table.insert(board.items, n) + end end end local idx = sel_index() or math.min(prev_pos, math.max(#board.items, 1)) @@ -138,21 +278,81 @@ local function filter_changed() apply_filter() end -local function age(updated_at) - local secs = math.max(os.time() - (updated_at or 0), 0) - for _, u in ipairs(AGE_UNITS) do - if secs >= u[1] then - return math.floor(secs / u[1]) .. u[2] .. " ago" - end +-- Forward-declare before callbacks that invoke it; a later `local render` +-- would make those callbacks resolve a nil global instead. +local render + +local function set_sel(i) + board.sel_id = board.items[i] and board.items[i].id or nil + board.confirm = nil + render() +end + +local function move_sel(delta, wrap) + local n = #board.items + if n == 0 then + return + end + local cur = sel_index() or 1 + if wrap then + set_sel((cur - 1 + delta) % n + 1) + else + set_sel(math.min(math.max(cur + delta, 1), n)) end - return "just now" end -local function dispw(s) - return utf8.len(s) or #s +local function page_size() + return math.max(board.height - board.reserved - 1, 1) +end + +local function toggle_expand() + local s = selected() + if not s or #s.children == 0 then + return + end + s.expanded = not s.expanded + apply_filter() + render() end -local function render() +local function collapse_or_parent() + local s = selected() + if not s then + return + end + if #s.children > 0 and s.expanded then + s.expanded = false + apply_filter() + render() + return + end + local parent_id = s.group_id or s.parent_id + if parent_id then + board.sel_id = parent_id + apply_filter() + render() + end +end + +local function update_footer() + if board.rename then + board.win:set_config({ footer = RENAME_KEYS }) + return + end + local footer = {} + if board.counts.needs_input > 0 then + footer[#footer + 1] = { "◆ " .. board.counts.needs_input, "needs input" } + end + if board.counts.working > 0 then + footer[#footer + 1] = { "● " .. board.counts.working, "working" } + end + for _, f in ipairs(FILTER_KEYS) do + footer[#footer + 1] = f + end + board.win:set_config({ footer = footer }) +end + +render = function() local lines = {} local inner = board.width - 4 local input = board.rename and board.rename.input or board.input @@ -172,21 +372,30 @@ local function render() local selected = s.id == board.sel_id local icon, icon_style, spinning = icon_of(s) local base = selected and "selected" or "item" - local right = s.focused and CURRENT_LABEL or age(s.updated_at) - local right_style = selected and "selected" or (s.focused and "accent" or "dim") + local right, right_style + if s.is_group then + local count = #s.children + right = task_count(count) + right_style = selected and "selected" or "dim" + else + right = s.focused and CURRENT_LABEL or age(s.updated_at) + right_style = selected and "selected" or (s.focused and "accent" or "dim") + end if selected then icon_style = "selected" end - -- Prefix after selection so a working row keeps animating host-side - -- on the selection background. if spinning then icon_style = "spinner:" .. icon_style end - local line = { { " ", base }, { icon, icon_style } } - for _, sp in ipairs(ListPicker.highlight_spans(s.title, words, base, selected and "match_selected" or "match")) do + local expand = (#s.children > 0) and (s.expanded and "▾ " or "▸ ") or " " + local indent = string.rep(" ", s.depth or 0) + local line = { { indent, base }, { expand, base }, { icon, icon_style } } + for _, sp in + ipairs(ListPicker.highlight_spans(s.display_title, words, base, selected and "match_selected" or "match")) + do line[#line + 1] = sp end - local used = 2 + dispw(icon) + dispw(s.title) + local used = (2 * (s.depth or 0)) + dispw(expand) + dispw(icon) + dispw(s.display_title) if board.confirm == s.id then line[#line + 1] = { CONFIRM_HINT, selected and "match_selected" or "error" } used = used + dispw(CONFIRM_HINT) @@ -202,18 +411,19 @@ local function render() if board.loading then lines[#lines + 1] = { { LOADING_HINT, "dim" } } elseif #board.items == 0 then - lines[#lines + 1] = { { #board.all == 0 and EMPTY_HINT or NO_MATCHES_HINT, "dim" } } + lines[#lines + 1] = { { #board.nodes == 0 and EMPTY_HINT or NO_MATCHES_HINT, "dim" } } end board.buf:set_lines(lines) board.win:set_cursor(cursor_line) end --- Rebuilds the list from live runtimes and the stored snapshot, then --- renders. Live runtimes win over their stored copies; stored-only sessions --- are idle. Until the background scan lands (`board.stored`) only live --- sessions are shown. `live()` suspends this coroutine, and the picker may --- close or another refresh may finish meanwhile, so bail out unless this --- board is still current. +-- Rebuilds the tree from live runtimes and the stored snapshot, then +-- renders. Live runtimes win over their stored copies for status and focus, +-- but the stored copy contributes its richer metadata (display_title, +-- parent_id, kind) for grouping and labelling. Until the background scan +-- lands (`board.stored`) only live sessions are shown. `live()` suspends +-- this coroutine, and the picker may close or another refresh may finish +-- meanwhile, so bail out unless this board is still current. local function refresh() local this_board = board local live, live_err = n00n.session.live() @@ -225,27 +435,50 @@ local function refresh() render() return end + + local stored_map = {} + for _, st in ipairs(board.stored or {}) do + stored_map[st.id] = st + end + + local expanded_state = {} + for _, n in ipairs(board.nodes or {}) do + expanded_state[n.id] = n.expanded + end + local seen, all = {}, {} for _, s in ipairs(live) do seen[s.id] = true s.live = true + local st = stored_map[s.id] + if st then + s.display_title = s.display_title or st.display_title + s.title = s.title or st.title + s.kind = s.kind or st.kind + s.parent_id = s.parent_id or st.parent_id + s.updated_at = s.updated_at or st.updated_at + end + normalize_session(s, expanded_state) all[#all + 1] = s end - for _, s in ipairs(board.stored or {}) do - if not seen[s.id] then - s.status = "idle" - s.focused = false - all[#all + 1] = s + for _, st in ipairs(board.stored or {}) do + if not seen[st.id] then + st.status = "idle" + st.focused = false + normalize_session(st, expanded_state) + all[#all + 1] = st end end + board.counts = { needs_input = 0, working = 0 } for _, s in ipairs(all) do if board.counts[s.status] then board.counts[s.status] = board.counts[s.status] + 1 end end + if board.loading then - table.sort(all, by_recency) + assign_ranks(all) else local fresh = {} for _, s in ipairs(all) do @@ -256,14 +489,16 @@ local function refresh() if #fresh > 0 then assign_ranks(fresh) end - table.sort(all, function(a, b) - if a.focused ~= b.focused then - return a.focused - end - return board.rank[a.id] < board.rank[b.id] - end) end - board.all = all + table.sort(all, by_recency) + + board.nodes = all + board.roots = build_tree(all) + for _, root in ipairs(board.roots) do + group_node(root, all, board.rank, expanded_state) + end + table.sort(all, by_recency) + apply_filter() update_footer() render() @@ -276,39 +511,15 @@ local function close() end end -local function selected() - local idx = sel_index() - return idx and board.items[idx] or nil -end - -local function set_sel(i) - board.sel_id = board.items[i] and board.items[i].id or nil - board.confirm = nil - render() -end - -local function move_sel(delta, wrap) - local n = #board.items - if n == 0 then - return - end - local cur = sel_index() or 1 - if wrap then - set_sel((cur - 1 + delta) % n + 1) - else - set_sel(math.min(math.max(cur + delta, 1), n)) - end -end - -local function page_size() - return math.max(board.height - board.reserved - 1, 1) -end - local function open_selected() local s = selected() if not s then return end + if s.is_group then + toggle_expand() + return + end if not s.focused then local _, err = n00n.session.focus(s.id) if err then @@ -333,6 +544,10 @@ local function delete_selected() if not s then return end + if s.is_group then + n00n.ui.flash(CANNOT_DELETE_GROUP_HINT) + return + end if s.focused then n00n.ui.flash(DELETE_FOCUSED_HINT) return @@ -358,11 +573,11 @@ end local function start_rename() local s = selected() - if not s then + if not s or s.is_group then return end local input = TextInput.new() - input:insert_text(s.title) + input:insert_text(s.display_title or s.title) board.rename = { id = s.id, input = input } board.confirm = nil update_footer() @@ -378,17 +593,29 @@ end local function commit_rename() local title = board.rename.input:value():match("^%s*(.-)%s*$") local id = board.rename.id + local current = selected() + local stored_title = current and current.kind ~= "main" and (current.kind .. ": " .. title) or title stop_rename() if title == "" then return end - local _, err = n00n.session.set_title({ id = id, title = title }) + local _, err = n00n.session.set_title({ id = id, title = stored_title }) if err then n00n.ui.flash(err) else + for _, n in ipairs(board.nodes) do + if n.id == id then + n.title = stored_title + n.display_title = title + end + end local si = find_stored(id) if si then - board.stored[si].title = title + board.stored[si].title = stored_title + board.stored[si].display_title = title + if current then + board.stored[si].kind = current.kind + end end end refresh() @@ -430,6 +657,10 @@ local function handle_key(key) move_sel(-page_size()) elseif key == "pagedown" then move_sel(page_size()) + elseif key == "right" then + toggle_expand() + elseif key == "left" then + collapse_or_parent() elseif key == "enter" then open_selected() elseif key == "ctrl+n" then @@ -472,7 +703,8 @@ local function open() height = win.height, reserved = 2, input = TextInput.new(), - all = {}, + nodes = {}, + roots = {}, items = {}, rank = {}, deleted = {}, @@ -482,9 +714,6 @@ local function open() frame = 0, loading = true, } - -- Two-phase load: live sessions are cheap, so they show up and take keys - -- right away; the stored scan can be slow, so a background task merges it - -- in once it lands. refresh() local this_board = board n00n.async.run(function() diff --git a/site/docs/content/lua-api/_index.md b/site/docs/content/lua-api/_index.md index a19694b4a..c218752a8 100644 --- a/site/docs/content/lua-api/_index.md +++ b/site/docs/content/lua-api/_index.md @@ -2642,7 +2642,8 @@ n00n.session.list() Lists sessions stored for the current project. Answered from a background scan, so a slow disk never blocks the UI. -**Returns:** (`table|nil`, `string|nil`) Array of `{id, title, updated_at}`, or nil and an error. +**Returns:** (`table|nil`, `string|nil`) Array of `{id, title, display_title, kind, +parent_id, updated_at, cwd, model}`, or nil and an error. **Example:** @@ -2762,8 +2763,9 @@ Starts a new session in the current project. - `{opts?}` (`table?`) Optional fields: prompt (string) first user message - to submit right away; focus (boolean) switch the UI to the new session. + to submit right away; focus (boolean) switch the UI to the new session; + - `parent_id` (`string?`) session that spawned this session. **Returns:** (`string|nil`, `string|nil`) New session id, or nil and an error.