From 3c62e468e9e6b55cf9d34fe122b2dca46b9be37d Mon Sep 17 00:00:00 2001 From: Wang Date: Wed, 4 Nov 2026 16:41:55 +0800 Subject: [PATCH 1/2] fix(workbench): manage child conversations and read archived sessions Reuse conversation actions inside the child tree modal, preserve loaded rows during branch refresh, and allow exact-tail read acknowledgement without restoring archived sessions. Cover lifecycle actions and archived read boundaries with real Host and desktop behavior tests. Generated-by: Maka --- plugins/agent/native/src/owner.rs | 2 +- .../src/owner/tests/session_availability.rs | 98 +++++++++++++++++++ .../tests/real_host/desktop_new_session.rs | 84 +++++++++++++++- .../maka/workbench/app/SessionActions.tsx | 13 +++ products/maka/workbench/app/SessionTree.tsx | 79 +++++++++++++-- 5 files changed, 266 insertions(+), 10 deletions(-) diff --git a/plugins/agent/native/src/owner.rs b/plugins/agent/native/src/owner.rs index 654b8317f22..1d206c6dd33 100644 --- a/plugins/agent/native/src/owner.rs +++ b/plugins/agent/native/src/owner.rs @@ -993,7 +993,7 @@ impl Owner { .get_session::(&input.session_id) .await .map_err(|_| "agent_store_unavailable")?; - if session.is_none_or(|session| session.archived) { + if session.is_none() { return Ok(MarkReadResult::Rejected { reason: Rejection::SessionUnavailable, }); diff --git a/plugins/agent/native/src/owner/tests/session_availability.rs b/plugins/agent/native/src/owner/tests/session_availability.rs index 6d6d39c04b7..b68951c2e2d 100644 --- a/plugins/agent/native/src/owner/tests/session_availability.rs +++ b/plugins/agent/native/src/owner/tests/session_availability.rs @@ -43,6 +43,8 @@ async fn session_read_guards_reject_foreign_missing_and_logically_removed_sessio ]; for id in ["foreign_session", missing.as_str(), session.as_str()] { assert_eq!(fixture.owner.require_session(id).await.unwrap_err(), "agent_session_unavailable"); + assert_eq!(fixture.mutation(2, "markRead", json!({"sessionId":id,"messageId":"unavailable-tail"}), Uuid::new_v4()).await.unwrap(), + json!({"kind":"rejected","reason":"session_unavailable"})); for (method, input) in &requests { let mut input = input.clone(); input["sessionId"] = json!(id); @@ -127,3 +129,99 @@ async fn history_visibility_does_not_decode_configuration_but_source_still_requi .await .unwrap(); } + +#[tokio::test] +async fn archived_session_mark_read_acknowledges_only_the_exact_tail_without_restoring() { + tokio::time::timeout(Duration::from_secs(30), async { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let fixture = Fixture::new( + &format!("http://{}/v1", listener.local_addr().unwrap()), + None, + ) + .await; + let session = fixture.create("Archived unread conversation").await; + let mut tails = Vec::new(); + for _ in 0..2 { + fixture.send(&session).await.unwrap(); + let (mut socket, _) = listener.accept().await.unwrap(); + request(&mut socket).await; + response(&mut socket).await; + fixture.drained().await; + let observed = fixture + .query("observe", json!({"sessionId":session})) + .await + .unwrap(); + assert_eq!(observed["session"]["hasUnread"], true); + tails.push( + observed["session"]["tailMessageId"] + .as_str() + .unwrap() + .to_owned(), + ); + } + assert_ne!(tails[0], tails[1]); + let revision = fixture.owner.session(&session).await.unwrap().revision; + let archived = fixture + .mutation( + 2, + "changeSession", + json!({ + "sessionId":session,"expectedRevision":revision,"action":"archive" + }), + Uuid::new_v4(), + ) + .await + .unwrap(); + assert_eq!(archived["kind"], "changed"); + assert_eq!(archived["session"]["archived"], true); + assert_eq!(archived["session"]["hasUnread"], true); + + let stale = fixture + .mutation( + 2, + "markRead", + json!({ + "sessionId":session,"messageId":tails[0] + }), + Uuid::new_v4(), + ) + .await + .unwrap(); + assert_eq!(stale["kind"], "observed"); + assert_eq!(stale["session"]["hasUnread"], true); + assert_eq!( + stale["session"]["revision"], + archived["session"]["revision"] + ); + + let read = fixture + .mutation( + 2, + "markRead", + json!({ + "sessionId":session,"messageId":tails[1] + }), + Uuid::new_v4(), + ) + .await + .unwrap(); + assert_eq!(read["kind"], "observed"); + assert_eq!(read["session"]["archived"], true); + assert_eq!(read["session"]["hasUnread"], false); + let observed = fixture + .query("observe", json!({"sessionId":session})) + .await + .unwrap(); + assert_eq!(observed["session"]["archived"], true); + assert_eq!(observed["session"]["hasUnread"], false); + let stored = fixture.owner.session(&session).await.unwrap(); + assert_eq!( + stored.read_state.last_read_message_id.as_deref(), + Some(tails[1].as_str()) + ); + fixture.app.shutdown().await.unwrap(); + fixture.config.shutdown().await.unwrap(); + }) + .await + .unwrap(); +} diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index c3d97f20299..742615f5d12 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -2264,7 +2264,7 @@ async fn desktop_parent_reviews_child_access_and_opens_attributed_sender_beside( } #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn desktop_child_conversations_expand_archived_ancestors_and_open_a_grandchild() { +async fn desktop_child_conversations_manage_nested_and_archived_workers() { use maka_agent_api::session::StoredSessionConfiguration; let fixture = Fixture::open().await; let original = fixture @@ -2280,9 +2280,11 @@ async fn desktop_child_conversations_expand_archived_ancestors_and_open_a_grandc let prefix = fixture.session.rsplit_once('_').unwrap().0; let child = format!("{prefix}_{}", Uuid::new_v4().simple()); let grandchild = format!("{prefix}_{}", Uuid::new_v4().simple()); + let disposable = format!("{prefix}_{}", Uuid::new_v4().simple()); for (id, parent, title) in [ (&child, &fixture.session, "Archived worker"), (&grandchild, &child, "Verification worker"), + (&disposable, &fixture.session, "Disposable worker"), ] { let mut configuration = original.clone(); configuration.parent_session_id = Some(parent.clone()); @@ -2334,6 +2336,86 @@ async fn desktop_child_conversations_expand_archived_ancestors_and_open_a_grandc "deep rows stay inside the dialog's viewport" ); desktop.artifact(&format!("child-conversations-{theme}-{layout}")); + for (id, title, action, archived) in [ + ( + &grandchild, + "Verification worker", + "Archive conversation", + true, + ), + ( + &grandchild, + "Verification worker", + "Restore conversation", + false, + ), + (&child, "Archived worker", "Restore conversation", false), + ( + &disposable, + "Disposable worker", + "Archive conversation", + true, + ), + ] { + desktop.right_click(&format!("Open {title}")).await; + desktop.control(action).await; + desktop.artifact(&format!( + "child-menu-{}-{theme}-{layout}", + if archived { "archive" } else { "restore" } + )); + desktop.press(action).await; + desktop + .wait("tree action did not change the targeted worker", async { + loop { + let observed = desktop + .client + .query("agent", "observe", json!({"sessionId":id})) + .await + .unwrap(); + if observed["session"]["archived"] == archived { + break; + } + tokio::task::yield_now().await; + } + }) + .await; + desktop + .until("tree action menu did not settle", || { + desktop.controls(action).is_empty().then_some(()) + }) + .await; + desktop.control("Open Verification worker").await; + } + desktop.press("Actions for Disposable worker").await; + desktop.press("Delete conversation").await; + desktop.control("Confirm delete conversation").await; + assert!( + fixture + .store + .get_session::(&disposable) + .await + .unwrap() + .is_some(), + "opening delete confirmation must not delete the worker" + ); + desktop.artifact(&format!("child-delete-confirmation-{theme}-{layout}")); + desktop.press("Confirm delete conversation").await; + desktop + .until("deleted worker remained in the tree", || { + desktop + .controls("Open Disposable worker") + .is_empty() + .then_some(()) + }) + .await; + assert!( + fixture + .store + .get_session::(&disposable) + .await + .unwrap() + .is_none() + ); let previous = desktop.platform.text_input_target(); desktop.press("Open Verification worker").await; desktop.target_after(previous).await; diff --git a/products/maka/workbench/app/SessionActions.tsx b/products/maka/workbench/app/SessionActions.tsx index 8ac9b7b913d..fe83eff7bd6 100644 --- a/products/maka/workbench/app/SessionActions.tsx +++ b/products/maka/workbench/app/SessionActions.tsx @@ -360,6 +360,19 @@ export function SessionActions(props: { session={session()} current={() => active() && page() === 'children'} close={props.close} + renderActions={(target) => ( + { + await props.change(action); + await target.refresh(); + }} + /> + )} openSession={(id) => { props.close(); props.openSession?.(id); diff --git a/products/maka/workbench/app/SessionTree.tsx b/products/maka/workbench/app/SessionTree.tsx index 31ea0068295..1c03892f718 100644 --- a/products/maka/workbench/app/SessionTree.tsx +++ b/products/maka/workbench/app/SessionTree.tsx @@ -17,7 +17,18 @@ * under the License. */ -import { cleanup, computed, inputAction, List, Scroll, signal, Text, View } from 'nsh'; +import { + cleanup, + computed, + inputAction, + List, + Scroll, + signal, + Text, + View, + type ViewRef, + type JSX, +} from 'nsh'; import { Button, Dialog } from 'nsh/base'; import type { AgentSession, AgentSessionPage } from '../../../../packages/plugin-sdk/src/index.js'; import { useHost } from './host'; @@ -39,11 +50,17 @@ import { textTooltip, } from './ui'; -type TreeSession = Pick & - Partial> & { workspace?: Pick }; +type TreeSession = SessionActionTarget & Partial>; +export type TreeActions = { + session: SessionActionTarget; + anchor(): ViewRef | null; + refresh(): Promise; + current(): boolean; + close(): void; +}; type Branch = { page: AgentSessionPage | null; loading: boolean; error: UiText }; type Row = { key: string; depth: number } & ( - | { kind: 'session'; session: TreeSession } + | { kind: 'session'; session: TreeSession; parentId: string | null } | { kind: 'branch'; parent: TreeSession } ); @@ -52,8 +69,10 @@ export function SessionTree(props: { current(): boolean; close(): void; openSession(id: string): void; + renderActions(target: TreeActions): JSX.Element; }) { const host = useHost(); + const actions = signal(null); const branches = signal>(new Map()); const expanded = signal>(new Set([props.session.id])); const viewportWidth = signal(0); @@ -66,8 +85,9 @@ export function SessionTree(props: { const load = async (id: string, restart = false) => { const previous = branches().get(id); if (!active() || previous?.loading) return; - const page = restart ? null : (previous?.page ?? null); - update(id, { page, loading: true, error: '' }); + const displayed = previous?.page ?? null; + const page = restart ? null : displayed; + update(id, { page: displayed, loading: true, error: '' }); try { const next = await host.query('agent', 'list', { filter: 'all', @@ -86,7 +106,7 @@ export function SessionTree(props: { error: '', }); } catch (reason) { - if (active()) update(id, { page, loading: false, error: errorText(reason) }); + if (active()) update(id, { page: displayed, loading: false, error: errorText(reason) }); } }; const toggle = (id: string) => { @@ -108,7 +128,7 @@ export function SessionTree(props: { const rows = computed(() => { const rows: Row[] = []; const stack: Row[] = [ - { kind: 'session', key: props.session.id, depth: 0, session: props.session }, + { kind: 'session', key: props.session.id, depth: 0, session: props.session, parentId: null }, ]; while (stack.length) { const row = stack.pop()!; @@ -135,6 +155,7 @@ export function SessionTree(props: { key: children[index]!.id, depth: row.depth + 1, session: children[index]!, + parentId: row.session.id, }); } return rows; @@ -267,6 +288,24 @@ export function SessionTree(props: { ); } const session = () => (value() as Extract).session; + const actionTrigger = signal(null); + cleanup(() => { + actionTrigger.set(null); + }); + const showActions = (target: TreeSession) => { + if (!active() || !actionTrigger()) return; + const menu: TreeActions = { + session: target, + anchor: actionTrigger, + refresh: async () => { + if (row.parentId === null) close(); + else await load(row.parentId, true); + }, + current: () => active() && actions() === menu, + close: () => actions.set(null), + }; + actions.set(menu); + }; const branch = () => branches().get(session().id); const leaf = () => branch()?.page?.sessions.length === 0 && !branch()?.page?.after; @@ -286,6 +325,7 @@ export function SessionTree(props: { return ( + ); }} @@ -357,6 +416,10 @@ export function SessionTree(props: { + {/* Create menus inside the tree's modal scope. */} + target}> + {(item) => props.renderActions(item())} + ); } From 4772f89ca19b6e35eb3d85da7ff6ac2554fb35d3 Mon Sep 17 00:00:00 2001 From: M4n5ter Date: Fri, 6 Nov 2026 10:59:05 +0800 Subject: [PATCH 2/2] fix(workbench): serialize child tree actions and stabilize list keys Normalize SessionTree rows by stable string keys, serialize child conversation mutations and quiet branch refreshes through TreeActions.run, and wait for the tree to become interactive again in the desktop behavior test. Generated-by: Antigravity --- .../tests/real_host/desktop_new_session.rs | 9 +- .../maka/workbench/app/SessionActions.tsx | 5 +- products/maka/workbench/app/SessionTree.tsx | 160 +++++++++++++----- 3 files changed, 123 insertions(+), 51 deletions(-) diff --git a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs index 742615f5d12..ea3a74a4f1c 100644 --- a/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs +++ b/products/maka/terminal-shell/tests/real_host/desktop_new_session.rs @@ -2381,7 +2381,9 @@ async fn desktop_child_conversations_manage_nested_and_archived_workers() { .await; desktop .until("tree action menu did not settle", || { - desktop.controls(action).is_empty().then_some(()) + (desktop.controls(action).is_empty() + && desktop.controls("Close child conversations").len() == 1) + .then_some(()) }) .await; desktop.control("Open Verification worker").await; @@ -2402,9 +2404,8 @@ async fn desktop_child_conversations_manage_nested_and_archived_workers() { desktop.press("Confirm delete conversation").await; desktop .until("deleted worker remained in the tree", || { - desktop - .controls("Open Disposable worker") - .is_empty() + (desktop.controls("Open Disposable worker").is_empty() + && desktop.controls("Close child conversations").len() == 1) .then_some(()) }) .await; diff --git a/products/maka/workbench/app/SessionActions.tsx b/products/maka/workbench/app/SessionActions.tsx index fe83eff7bd6..4091f89b9a5 100644 --- a/products/maka/workbench/app/SessionActions.tsx +++ b/products/maka/workbench/app/SessionActions.tsx @@ -367,10 +367,7 @@ export function SessionActions(props: { current={target.current} close={target.close} notice={props.notice} - change={async (action) => { - await props.change(action); - await target.refresh(); - }} + change={(action) => target.run(() => props.change(action))} /> )} openSession={(id) => { diff --git a/products/maka/workbench/app/SessionTree.tsx b/products/maka/workbench/app/SessionTree.tsx index 1c03892f718..c0e9ce1fb57 100644 --- a/products/maka/workbench/app/SessionTree.tsx +++ b/products/maka/workbench/app/SessionTree.tsx @@ -54,16 +54,33 @@ type TreeSession = SessionActionTarget & Partial> export type TreeActions = { session: SessionActionTarget; anchor(): ViewRef | null; - refresh(): Promise; + run(operation: () => Promise): Promise; current(): boolean; close(): void; }; -type Branch = { page: AgentSessionPage | null; loading: boolean; error: UiText }; +type Branch = { + page: AgentSessionPage | null; + loading: boolean; + refreshing: boolean; + error: UiText; +}; type Row = { key: string; depth: number } & ( | { kind: 'session'; session: TreeSession; parentId: string | null } | { kind: 'branch'; parent: TreeSession } ); +function actionTarget(session: TreeSession): SessionActionTarget { + return { + id: session.id, + revision: session.revision, + title: session.title, + labels: [...session.labels], + archived: session.archived, + pinned: session.pinned, + workspace: { path: session.workspace.path }, + }; +} + export function SessionTree(props: { session: SessionActionTarget; current(): boolean; @@ -73,21 +90,27 @@ export function SessionTree(props: { }) { const host = useHost(); const actions = signal(null); + const busy = signal(false); const branches = signal>(new Map()); const expanded = signal>(new Set([props.session.id])); const viewportWidth = signal(0); + const requests = new Map(); let alive = true; cleanup(() => { alive = false; }); const active = () => alive && props.current(); + const interactive = () => active() && !busy(); const update = (id: string, branch: Branch) => branches.set(new Map(branches()).set(id, branch)); const load = async (id: string, restart = false) => { const previous = branches().get(id); - if (!active() || previous?.loading) return; + if (!active() || (!restart && (previous?.loading || previous?.refreshing))) return; + const request = (requests.get(id) ?? 0) + 1; + requests.set(id, request); const displayed = previous?.page ?? null; const page = restart ? null : displayed; - update(id, { page: displayed, loading: true, error: '' }); + const quiet = restart && Boolean(displayed?.sessions.length); + update(id, { page: displayed, loading: !quiet, refreshing: quiet, error: '' }); try { const next = await host.query('agent', 'list', { filter: 'all', @@ -97,20 +120,27 @@ export function SessionTree(props: { ? { kind: 'after', revision: page.revision, cursor: page.after } : { kind: 'start' }, }); - if (!active()) return; + if (!active() || requests.get(id) !== request) return; if (page && next.revision !== page.revision) throw new UiError('The conversation list changed. Refresh to continue.'); update(id, { page: page ? { ...next, sessions: [...page.sessions, ...next.sessions] } : next, loading: false, + refreshing: false, error: '', }); } catch (reason) { - if (active()) update(id, { page: displayed, loading: false, error: errorText(reason) }); + if (active() && requests.get(id) === request) + update(id, { + page: displayed, + loading: false, + refreshing: false, + error: errorText(reason), + }); } }; const toggle = (id: string) => { - if (!active()) return; + if (!interactive()) return; const next = new Set(expanded()); if (next.has(id)) next.delete(id); else { @@ -120,19 +150,23 @@ export function SessionTree(props: { expanded.set(next); }; const open = (id: string) => { - if (active()) props.openSession(id); + if (interactive()) props.openSession(id); }; const close = () => { - if (active()) props.close(); + if (interactive()) props.close(); }; - const rows = computed(() => { - const rows: Row[] = []; + const tree = computed(() => { + const keys: string[] = []; + const rowsByKey = new Map(); + let maxDepth = 0; const stack: Row[] = [ { kind: 'session', key: props.session.id, depth: 0, session: props.session, parentId: null }, ]; while (stack.length) { const row = stack.pop()!; - rows.push(row); + keys.push(row.key); + rowsByKey.set(row.key, row); + maxDepth = Math.max(maxDepth, row.depth); if (row.kind !== 'session' || !expanded().has(row.session.id)) continue; const branch = branches().get(row.session.id); if (!branch) continue; @@ -158,17 +192,16 @@ export function SessionTree(props: { parentId: row.session.id, }); } - return rows; + return { keys, rowsByKey, maxDepth }; }); - const contentWidth = () => - Math.max( - viewportWidth(), - 320 + rows().reduce((depth, row) => Math.max(depth, row.depth), 0) * 16, - ); + const contentWidth = () => Math.max(viewportWidth(), 320 + tree().maxDepth * 16); void load(props.session.id); return ( tr('Close'))} + disabled={!interactive()} onPress={inputAction(close, null)} style={{ ...controlStyle, width: 32, alignItems: 'center', justifyContent: 'center' }} hoverStyle={controlHoverStyle()} focusStyle={controlFocusStyle()} + disabledStyle={controlDisabledStyle} > @@ -216,24 +251,31 @@ export function SessionTree(props: { style={{ flex: 1, minHeight: 0 }} > row.key} + each={tree().keys} + keyExtractor={(key) => key} estimatedItemSize={48} role="tree" accessibilityLabel={tr('Child conversations')} style={{ width: contentWidth(), flex: 1, minHeight: 0 }} > {(item) => ( - row.key}> - {(value) => { - const row = value(); - if (row.kind === 'branch') { - const branch = () => branches().get(row.parent.id)!; + key}> + {(key) => { + let last = tree().rowsByKey.get(key())!; + const currentRow = () => { + const next = tree().rowsByKey.get(key()); + if (next) last = next; + return last; + }; + const initial = currentRow(); + if (initial.kind === 'branch') { + const parent = () => (currentRow() as Extract).parent; + const branch = () => branches().get(parent().id)!; return ( @@ -273,12 +317,14 @@ export function SessionTree(props: { > @@ -287,22 +333,43 @@ export function SessionTree(props: { ); } - const session = () => (value() as Extract).session; + const session = () => (currentRow() as Extract).session; + const present = () => tree().rowsByKey.has(key()); const actionTrigger = signal(null); cleanup(() => { actionTrigger.set(null); }); - const showActions = (target: TreeSession) => { - if (!active() || !actionTrigger()) return; + const showActions = (target: SessionActionTarget) => { + const current = session(); + if ( + !interactive() || + !present() || + !actionTrigger() || + current.id !== target.id || + current.revision !== target.revision + ) + return; const menu: TreeActions = { session: target, anchor: actionTrigger, - refresh: async () => { - if (row.parentId === null) close(); - else await load(row.parentId, true); + run: async (operation) => { + busy.set(true); + try { + await operation(); + if (initial.parentId === null) { + if (active()) props.close(); + } else { + await load(initial.parentId, true); + if (alive && actions() === menu) actions.set(null); + } + } finally { + if (alive) busy.set(false); + } }, current: () => active() && actions() === menu, - close: () => actions.set(null), + close: () => { + if (actions() === menu) actions.set(null); + }, }; actions.set(menu); }; @@ -325,15 +392,19 @@ export function SessionTree(props: { return ( session().title)} - disabled={!active()} + disabled={!interactive() || !present()} onPress={inputAction(open, session().id)} style={{ ...controlStyle, @@ -395,8 +468,8 @@ export function SessionTree(props: { }} accessibilityLabel={tr('Actions for {title}', { title: session().title })} tooltip={textTooltip(() => tr('Conversation actions'))} - disabled={!active()} - onPress={inputAction(showActions, session())} + disabled={!interactive() || !present()} + onPress={inputAction(showActions, actionTarget(session()))} style={{ ...controlStyle, width: 32, @@ -405,6 +478,7 @@ export function SessionTree(props: { }} hoverStyle={controlHoverStyle()} focusStyle={controlFocusStyle()} + disabledStyle={controlDisabledStyle} >