Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5db3208
feat(ui): group child sessions by role
w0wl0lxd Aug 3, 2026
5c3e299
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 3, 2026
ee1326b
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 4, 2026
0a30391
fix(lua): escape pattern words in has_word and deduplicate title fields
w0wl0lxd Aug 4, 2026
9ac9a33
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 4, 2026
6bb8f42
fix(sessions): guard rank lookup and simplify category text
w0wl0lxd Aug 4, 2026
f8be865
Merge remote-tracking branch 'origin/main' into fix/session-category-…
w0wl0lxd Aug 4, 2026
13ff7cf
fix(sessions): keep category nodes ranked before their buckets
w0wl0lxd Aug 4, 2026
4f1f294
Merge remote-tracking branch 'origin/main' into fix/session-category-…
w0wl0lxd Aug 4, 2026
5638aa2
fix(sessions): treat any non-alphanumeric character as a word boundar…
w0wl0lxd Aug 4, 2026
f79f900
Merge remote-tracking branch 'origin/main' into fix/session-category-…
w0wl0lxd Aug 4, 2026
ac23456
test(plugin_host): use test_case attribute for session category test
w0wl0lxd Aug 5, 2026
dc88559
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 5, 2026
39a6796
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 5, 2026
aab824e
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 5, 2026
d684cbc
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 7, 2026
63757d8
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 7, 2026
0cfb72c
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 7, 2026
137af13
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 7, 2026
9281555
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 7, 2026
e98b01b
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 7, 2026
45cbae9
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 12, 2026
43670d0
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 12, 2026
4000c0c
Merge branch 'main' into fix/session-category-groups
w0wl0lxd Aug 12, 2026
f6bc162
fix(sessions): preserve orphan session subtrees
w0wl0lxd Aug 12, 2026
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-category-groups.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Group child sessions by research, planning, review, and orchestration role in the session picker.
133 changes: 133 additions & 0 deletions n00n-lua/tests/plugin_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2862,6 +2862,139 @@ n00n.api.register_tool({
);
}

#[test_case::test_case(())]
fn sessions_picker_groups_descendants_by_category_without_dropping_orphans(_: ()) {
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_category_probe",
description = "test",
schema = { type = "object", properties = {} },
audiences = { "main" },
handler = function()
local sessions = {
{ id = "root", kind = "main", updated_at = 100, children = {} },
{ id = "research", kind = "task", title = "Research: storage", parent_id = "root", updated_at = 90, children = {} },
{ id = "team", kind = "team", parent_id = "root", updated_at = 80, children = {} },
{ id = "workflow", kind = "workflow", parent_id = "root", updated_at = 70, children = {} },
{ id = "agent", kind = "task", title = "Implement fix", parent_id = "root", updated_at = 60, children = {} },
{ id = "review", kind = "task", display_title = "Review patch", parent_id = "team", updated_at = 50, children = {} },
{ id = "orphan", kind = "task", parent_id = "missing", group_id = "stale", updated_at = 40, children = {} },
{ id = "other-root", kind = "main", updated_at = 30, children = {} },
}
for i = 1, 21 do
sessions[#sessions + 1] = {
id = "agent-" .. i,
kind = "task",
title = "Implement " .. i,
parent_id = "root",
updated_at = 30 - i,
children = {},
}
sessions[#sessions + 1] = {
id = "orphan-child-" .. i,
kind = "task",
parent_id = "orphan",
updated_at = 0 - i,
children = {},
}
end
normalize_session(sessions[7], {})
local rank = {}
for i, session in ipairs(sessions) do
if session.id ~= "agent" then
rank[session.id] = i
end
end
board = { rank = rank }
local roots, nodes = build_tree(sessions, {}, rank)
local root
local orphan
local root_ids = {}
for _, candidate in ipairs(roots) do
root_ids[#root_ids + 1] = candidate.id
if candidate.id == "root" then
root = candidate
elseif candidate.id == "orphan" then
orphan = candidate
end
group_node(candidate, nodes, rank, {})
end
local category_ids = {}
local agent_group
for _, category in ipairs(root.children) do
category_ids[#category_ids + 1] = category.id
if category.id == "group:root:agents" then
agent_group = category
end
end
return n00n.json.encode({
roots = root_ids,
categories = category_ids,
node_count = #nodes,
agent_total = agent_group.total_tasks,
agent_buckets = #agent_group.children,
category_precedes_bucket = rank[agent_group.id] < rank[agent_group.children[1].id],
missing_rank_bucket = (function()
local missing_rank = {}
local child = { id = "unranked", updated_at = 0, children = {} }
make_bucket(root, { child }, 1, 1, {}, missing_rank, {})
return missing_rank["group:root:1"]
end)(),
orphan_group_cleared = sessions[7].group_id == nil,
orphan_total = #orphan.children[1].children + #orphan.children[2].children,
orphan_buckets = #orphan.children,
orphan_child_parent = orphan.children[1].children[1].parent_id,
draft_category = descendant_category({ kind = "task", title = "Draft response" }),
ponder_category = descendant_category({ kind = "task", display_title = "Ponder options" }),
punctuation_category = descendant_category({ kind = "task", title = "Review(PR 42)?" }),
escaped_word_matches = not not has_word("phase+one?", "phase+one"),
escaped_word_rejects_pattern = not has_word("phasexone?", "phase+one"),
})
end,
})
"#,
);
host.load_source("sessions_category_test", &source).unwrap();

let output = exec_tool(&registry, "sessions_category_probe", serde_json::json!({})).unwrap();
let grouped: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(
grouped["roots"],
serde_json::json!(["root", "orphan", "other-root"])
);
assert_eq!(
grouped["categories"],
serde_json::json!([
"group:root:research",
"group:root:review",
"group:root:teams",
"group:root:workflows",
"group:root:agents"
])
);
assert_eq!(grouped["node_count"], serde_json::json!(59));
assert_eq!(grouped["agent_total"], serde_json::json!(22));
assert_eq!(grouped["agent_buckets"], serde_json::json!(2));
assert_eq!(grouped["category_precedes_bucket"], serde_json::json!(true));
assert_eq!(grouped["missing_rank_bucket"], serde_json::json!(-0.25));
assert_eq!(grouped["orphan_group_cleared"], serde_json::json!(true));
assert_eq!(grouped["orphan_total"], serde_json::json!(21));
assert_eq!(grouped["orphan_buckets"], serde_json::json!(2));
assert_eq!(grouped["orphan_child_parent"], serde_json::json!("orphan"));
assert_eq!(grouped["draft_category"], serde_json::json!("draft"));
assert_eq!(grouped["ponder_category"], serde_json::json!("ponder"));
assert_eq!(grouped["punctuation_category"], serde_json::json!("review"));
assert_eq!(grouped["escaped_word_matches"], serde_json::json!(true));
assert_eq!(
grouped["escaped_word_rejects_pattern"],
serde_json::json!(true)
);
}

#[test]
fn job_callback_finishes_after_handler_returns_nil() {
let reg = fresh_registry();
Expand Down
174 changes: 151 additions & 23 deletions plugins/sessions/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ local AGE_UNITS = {
local MAX_GROUP_CHILDREN = 20
local GROUP_PREFIX = "group:"
local GROUP_KIND = "group"
local GROUP_CATEGORIES = {
{ key = "research", label = "Research" },
{ key = "ponder", label = "Ponder" },
{ key = "draft", label = "Draft" },
{ key = "review", label = "Review" },
{ key = "teams", label = "Teams" },
{ key = "workflows", label = "Workflows" },
{ key = "agents", label = "Agents / tasks" },
{ key = "other", label = "Other" },
}
local NAMED_CATEGORIES = { "research", "ponder", "draft", "review" }
local FILTER_KEYS = {
{ "Enter", "open" },
{ "Ctrl+N", "new" },
Expand Down Expand Up @@ -149,35 +160,154 @@ local function normalize_session(s, expanded_state)
s.kind = s.kind or "main"
s.updated_at = s.updated_at or 0
s.children = {}
s.group_id = nil
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)
local function has_word(text, word)
text = string.lower(text or "")
word = string.lower(word)
local escaped = word:gsub("([^%w])", "%%%1")
local boundary = "[^%a%d]"
return text == word
or text:match("^" .. escaped .. boundary)
or text:match(boundary .. escaped .. boundary)
or text:match(boundary .. escaped .. "$")
end
Comment thread
w0wl0lxd marked this conversation as resolved.

local function descendant_category(session)
local kind = string.lower(session.kind or "")
if kind == "team" then
return "teams"
end
if kind == "workflow" then
return "workflows"
end
local text = session.display_title or session.title or ""
for _, category in ipairs(NAMED_CATEGORIES) do
if has_word(text, category) then
return category
end
end
if kind == "task" or kind == "agent" then
return "agents"
end
return "other"
end
Comment thread
coderabbitai[bot] marked this conversation as resolved.

local function main_ancestor(session, by_id)
local seen = {}
local current = session
while current and not seen[current.id] do
seen[current.id] = true
if string.lower(current.kind or "") == "main" and not current.parent_id then
return current
end
current = current.parent_id and by_id[current.parent_id] or nil
end
return nil
end

local function rank_before(rank, child_id, offset)
return (rank[child_id] or 0) - offset
end

local function synthetic_group(root, category, children, expanded_state, rank)
local id = GROUP_PREFIX .. root.id .. ":" .. category.key
local group = {
id = id,
title = category.label,
display_title = category.label,
kind = GROUP_KIND,
is_group = true,
children = children,
total_tasks = #children,
parent_id = root.id,
updated_at = children[1].updated_at,
focused = false,
live = false,
status = "idle",
expanded = expanded_state[id] or false,
depth = 0,
}
rank[id] = rank_before(rank, children[1].id, 0.5)
for _, child in ipairs(children) do
Comment thread
w0wl0lxd marked this conversation as resolved.
child.group_id = id
end
return group
end

local function build_tree(sessions)
local function build_tree(sessions, expanded_state, rank)
local by_id = {}
for _, s in ipairs(sessions) do
by_id[s.id] = s
for _, session in ipairs(sessions) do
by_id[session.id] = session
end

local descendants = {}
local root_mains = {}
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)
for _, session in ipairs(sessions) do
if string.lower(session.kind or "") == "main" and not session.parent_id then
roots[#roots + 1] = session
root_mains[session.id] = true
descendants[session.id] = {}
end
end

local orphans = {}
for _, session in ipairs(sessions) do
if not root_mains[session.id] then
local root = main_ancestor(session, by_id)
if root then
descendants[root.id][#descendants[root.id] + 1] = session
else
orphans[session.id] = true
end
end
end
sort_tree(roots)
return roots

for _, session in ipairs(sessions) do
if orphans[session.id] then
local parent = session.parent_id and by_id[session.parent_id] or nil
if parent and orphans[parent.id] and parent ~= session then
parent.children[#parent.children + 1] = session
else
roots[#roots + 1] = session
Comment thread
w0wl0lxd marked this conversation as resolved.
end
end
end

for _, root in ipairs(roots) do
if root_mains[root.id] then
local categorized = {}
for _, session in ipairs(descendants[root.id]) do
local key = descendant_category(session)
categorized[key] = categorized[key] or {}
categorized[key][#categorized[key] + 1] = session
end
for _, category in ipairs(GROUP_CATEGORIES) do
local children = categorized[category.key]
if children then
table.sort(children, by_recency)
root.children[#root.children + 1] = synthetic_group(root, category, children, expanded_state, rank)
end
end
end
end

local nodes = {}
local function collect(node)
nodes[#nodes + 1] = node
for _, child in ipairs(node.children) do
collect(child)
end
end
table.sort(roots, by_recency)
for _, root in ipairs(roots) do
collect(root)
end
return roots, nodes
end

local function task_count(count)
Expand Down Expand Up @@ -218,7 +348,7 @@ local function make_bucket(parent, children, start_idx, finish, all_nodes, rank,
expanded = expanded_state[bucket_id] or false,
depth = 0,
}
rank[bucket.id] = rank[children[start_idx].id] - 0.5
rank[bucket.id] = rank_before(rank, children[start_idx].id, 0.25)
for i = start_idx, finish do
local child = children[i]
child.group_id = bucket.id
Expand Down Expand Up @@ -374,8 +504,7 @@ render = function()
local base = selected and "selected" or "item"
local right, right_style
if s.is_group then
local count = #s.children
right = task_count(count)
right = task_count(s.total_tasks or #s.children)
right_style = selected and "selected" or "dim"
else
right = s.focused and CURRENT_LABEL or age(s.updated_at)
Expand Down Expand Up @@ -492,12 +621,11 @@ local function refresh()
end
table.sort(all, by_recency)

board.nodes = all
board.roots = build_tree(all)
board.roots, board.nodes = build_tree(all, expanded_state, board.rank)
for _, root in ipairs(board.roots) do
group_node(root, all, board.rank, expanded_state)
group_node(root, board.nodes, board.rank, expanded_state)
end
table.sort(all, by_recency)
table.sort(board.nodes, by_recency)

apply_filter()
update_footer()
Expand Down
Loading