Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/session-tree-ui.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Session titles now describe their first task, and the session picker groups sub-tasks under their parent session.
22 changes: 17 additions & 5 deletions n00n-lua/src/api/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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 })
Expand All @@ -124,14 +126,24 @@ async fn new(
#[ctx] tx: Option<flume::Sender<UiAction>>,
opts: Option<Table>,
) -> LuaResult<Pair> {
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
Expand Down
32 changes: 25 additions & 7 deletions n00n-lua/src/api/util/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,14 +406,32 @@ pub enum WinCommand {
pub enum SessionRequest {
List,
Live,
Status { id: String },
Status {
id: String,
},
Current,
New { prompt: Option<String>, focus: bool },
Prompt { id: Option<String>, text: String },
Cancel { id: String },
Focus { id: String },
Delete { id: String },
SetTitle { id: String, title: String },
New {
prompt: Option<String>,
focus: bool,
parent_id: Option<String>,
},
Prompt {
id: Option<String>,
text: String,
},
Cancel {
id: String,
},
Focus {
id: String,
},
Delete {
id: String,
},
SetTitle {
id: String,
title: String,
},
}

pub type SessionReply = Result<serde_json::Value, String>;
Expand Down
97 changes: 97 additions & 0 deletions n00n-lua/tests/plugin_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(&registry)).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(&registry, "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));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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();
Expand Down
Loading
Loading