diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client-subagent-roster.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client-subagent-roster.test.ts new file mode 100644 index 00000000..62960967 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client-subagent-roster.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, test } from "bun:test" +import { createDevoClient, type DevoNativeTransport, type DevoNativeTransportEvent } from "./client" + +const child = { + id: "child-1", + version: 1, + cwd: "/repo", + title: "Review tests", + parent: { kind: "agent", sessionId: "parent-1" }, + createdAt: "2026-11-08T00:00:00Z", + lastActivityAt: "2026-11-08T00:00:00Z", + status: "idle", + flags: ["waitingApproval"], + archived: false, + ephemeral: false, + activity: "working", + model: { provider: "test", model: "test" }, + settings: { permissionProfile: "default" }, + preview: "", + queuedCount: 1, + usage: { + total: { + inputTokens: 0, + outputTokens: 0, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + reasoningTokens: 0, + totalTokens: 0, + callCount: 0, + meteredCallCount: 0, + failedCallCount: 0, + cancelledCallCount: 0, + }, + byPurpose: [], + updatedAt: "2026-11-08T00:00:00Z", + }, +} + +class Transport implements DevoNativeTransport { + requests: Array<{ method: string; params: unknown }> = [] + error: Error | undefined + async request(method: string, params?: unknown) { + this.requests.push({ method, params }) + if (method === "initialize") + return { protocolVersion: 1, agentCapabilities: {}, authMethods: [] } + if (this.error) throw this.error + if (method === "session/read") return { session: child } + if (method === "session/list") { + return (params as { includeChildren?: boolean }).includeChildren + ? { data: [child], nextCursor: "200" } + : { data: [], nextCursor: null } + } + throw new Error(`Unexpected ${method}`) + } + async respond() {} + subscribe(_listener: (event: DevoNativeTransportEvent) => void) { + return () => {} + } + connected() { + return true + } +} + +describe("read-only child session discovery", () => { + test("roster forwards pagination and preserves Native flags without resuming or loading history", async () => { + const transport = new Transport() + const client = createDevoClient({ directory: "/repo", transport }) + try { + const params = { + cwds: [], + includeChildren: true, + limit: 200, + cursor: "100", + } + expect(await client.session.roster(params)).toEqual({ + data: [child], + nextCursor: "200", + }) + expect(transport.requests.filter((r) => r.method !== "initialize")).toEqual([ + { method: "session/list", params }, + ]) + } finally { + client.dispose() + } + }) + test("cold child lookup reads its exact ID instead of the root-only roster", async () => { + const transport = new Transport() + const client = createDevoClient({ transport }) + try { + const [first, second] = await Promise.all([ + client.session.get({ sessionId: child.id }), + client.session.get({ sessionId: child.id }), + ]) + expect(first).toEqual(second) + expect(first.data).toMatchObject({ + id: child.id, + parentId: "parent-1", + directory: "/repo", + }) + expect(transport.requests.filter((r) => r.method !== "initialize")).toEqual([ + { method: "session/read", params: { sessionId: child.id } }, + ]) + } finally { + client.dispose() + } + }) + test("missing children return undefined; connection errors are not disguised as missing", async () => { + const transport = new Transport() + const client = createDevoClient({ transport }) + try { + transport.error = Object.assign(new Error("gone"), { + code: "SessionNotFound", + }) + expect(await client.session.get({ sessionId: "missing" })).toEqual({ + data: undefined, + }) + transport.error = new Error("connection lost") + await expect(client.session.get({ sessionId: "missing" })).rejects.toThrow("connection lost") + } finally { + client.dispose() + } + }) +}) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index a64b77d7..eda2923b 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -77,6 +77,10 @@ export type { ReferenceSearchSnapshot, } from "./reference-search-session" +import type { SessionRosterParams, SessionRosterPage } from "./session-roster" +export type { Session as NativeSession } from "./generated/native" +export type { SessionRosterParams, SessionRosterPage } from "./session-roster" + export type JsonRpcId = number | string export interface DevoNativeTransportEvent { @@ -850,6 +854,9 @@ class NativeClient { list: async () => ({ data: await this.listProjects() }), } session = { + /** A single read-only Native roster page. Does not resume sessions or load transcripts. */ + roster: async (params: SessionRosterParams): Promise => + await this.requestCanonical("session/list", params) as SessionRosterPage, list: async (params?: { limit?: number; roots?: boolean; search?: string }) => ({ data: await this.listSessions(params), }), @@ -1679,8 +1686,14 @@ class NativeClient { private async discoverSession(sessionId: string): Promise { const pending = this.sessionDiscovery.get(sessionId) if (pending) return pending - const discovery = this.listSessions() - .then((sessions) => sessions.find((session) => session.id === sessionId)) + // Child sessions are absent from the default root-only roster. Resolve an + // exact ID without scanning the roster or resuming any work. + const discovery = this.requestCanonical("session/read", { sessionId }) + .then((result) => this.rememberNativeSession(result.session)) + .catch((error) => { + if (isSessionNotFoundError(error)) return undefined + throw error + }) .finally(() => { this.sessionDiscovery.delete(sessionId) }) diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/session-roster.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/session-roster.ts new file mode 100644 index 00000000..ea56fe96 --- /dev/null +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/session-roster.ts @@ -0,0 +1,16 @@ +import type { Session } from "./generated/native" + +/** Native session/list parameters. The protocol generator exports Session, + * but not the list RPC types; runtime validation still uses the Native schema. */ +export interface SessionRosterParams { + cwds?: string[] + search?: string + cursor?: string + limit?: number + includeChildren?: boolean +} + +export interface SessionRosterPage { + data: Session[] + nextCursor?: string | null +} diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index 299b2ce0..c42c48a2 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -44,6 +44,7 @@ import { setOpenInPreferred, } from "../services/backend" import { ChatView } from "./chat" +import { SubagentsButton } from "./subagents-button" import { ContextUsageButton } from "./context-usage-button" import { BottomPanelIcon, RightPanelIcon } from "./panel-icons" import { ReviewSideRail } from "./review/review-side-rail" @@ -415,6 +416,7 @@ export function SessionPanelHeader({
+ ({ + getProjectClient: () => ({ + session: { + roster: async () => { + calls++ + return page + }, + }, + }), +})) +mock.module("@tanstack/react-router", () => ({ + useNavigate: () => (route: unknown) => { + navigations.push(route) + }, +})) +const { SubagentsButton, subagentStatus } = await import("./subagents-button") +const { createRoot } = await import("react-dom/client") +let root: Root | undefined +let mount: HTMLElement +const agent = { + sessionId: "parent", + directory: "/repo", + projectSlug: "repo-1", +} as Agent +const child = { + id: "child", + title: "Check runtime", + parent: { kind: "agent", sessionId: "parent" }, + ephemeral: false, + flags: [], + status: "idle", + queuedCount: 0, +} as unknown as NativeSession + +async function render() { + mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + await act(async () => root?.render()) +} +function button(label: string) { + const element = document.querySelector(`button[aria-label="${label}"]`) + if (!element) throw new Error(`Missing ${label}`) + return element +} +async function click(element: HTMLElement) { + await act(async () => { + element.click() + await Bun.sleep(30) + }) +} +afterEach(async () => { + await act(async () => { + root?.unmount() + await Bun.sleep(30) + }) + root = undefined + mount?.remove() + page = { data: [], nextCursor: null } + calls = 0 + navigations.length = 0 +}) + +describe("subagent labels", () => { + for (const [patch, label] of [ + [{}, "Idle"], + [{ status: "active" }, "Working"], + [{ queuedCount: 2 }, "Queued"], + [{ status: "active", flags: ["waitingApproval"] }, "Needs approval"], + [{ flags: ["waitingUserInput"] }, "Needs input"], + [{ flags: ["compacting"] }, "Compacting"], + [{ flags: ["updatingGoal"] }, "Updating goal"], + ] as const) { + test(label, () => expect(subagentStatus({ ...child, ...patch } as NativeSession)).toBe(label)) + } +}) + +describe("subagent popover interactions", () => { + test("opens on demand, shows child state and navigates using the real session ID", async () => { + page = { + data: [{ ...child, flags: ["waitingApproval"] }], + nextCursor: null, + } + await render() + expect(calls).toBe(0) + await click(button("Subagents")) + expect(calls).toBe(1) + expect(document.body.textContent).toContain("Needs approval") + expect(document.body.textContent).toContain("Idle does not confirm completion") + const row = document.querySelector( + "ul[aria-label='Subagent sessions'] button", + ) + if (!row) throw new Error("Missing child row") + await click(row) + expect(navigations).toEqual([ + { + to: "/project/$projectSlug/session/$sessionId", + params: { projectSlug: "repo-1", sessionId: "child" }, + }, + ]) + expect(button("Subagents").getAttribute("aria-expanded")).toBe("false") + }) + test("close and Escape dismiss without navigation and restore trigger focus", async () => { + await render() + button("Subagents").focus() + await click(button("Subagents")) + await click(button("Close subagents")) + expect(button("Subagents").getAttribute("aria-expanded")).toBe("false") + expect(document.activeElement).toBe(button("Subagents")) + await click(button("Subagents")) + await act(async () => { + document.activeElement?.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ) + await Bun.sleep(30) + }) + expect(button("Subagents").getAttribute("aria-expanded")).toBe("false") + expect(navigations).toEqual([]) + }) + test("refresh updates empty state and does not start model work", async () => { + await render() + await click(button("Subagents")) + expect(document.body.textContent).toContain("No saved subagents") + page = { data: [child], nextCursor: null } + await click(button("Refresh subagents")) + expect(document.body.textContent).toContain("Check runtime") + expect(calls).toBe(2) + expect(navigations).toEqual([]) + }) +}) diff --git a/apps/desktop/src/renderer/components/subagents-button.tsx b/apps/desktop/src/renderer/components/subagents-button.tsx new file mode 100644 index 00000000..547b5958 --- /dev/null +++ b/apps/desktop/src/renderer/components/subagents-button.tsx @@ -0,0 +1,129 @@ +import { + Popover, + PopoverContent, + PopoverDescription, + PopoverTitle, + PopoverTrigger, +} from "@devo/ui/components/popover" +import type { NativeSession } from "@devo-ai/sdk/v2/client" +import { useNavigate } from "@tanstack/react-router" +import { ArrowUpRightIcon, GitBranchIcon, RefreshCwIcon, XIcon } from "lucide-react" +import { useState } from "react" +import { useSubagentRoster } from "../hooks/use-subagent-roster" +import type { Agent } from "../lib/types" + +/** Native idle means no active turn, not successful task completion. */ +export function subagentStatus(session: NativeSession): string { + if (session.flags.includes("waitingApproval")) return "Needs approval" + if (session.flags.includes("waitingUserInput")) return "Needs input" + if (session.flags.includes("compacting")) return "Compacting" + if (session.flags.includes("updatingGoal")) return "Updating goal" + if (session.status === "active") return "Working" + if (session.queuedCount > 0) return "Queued" + return "Idle" +} + +export function SubagentsButton({ agent }: { agent: Agent }) { + const [open, setOpen] = useState(false) + const navigate = useNavigate() + const roster = useSubagentRoster(agent.sessionId, agent.directory, open) + return ( + + + + +
+ Subagents + + +
+ + Direct children of this session. Refresh to check for changes. + + {roster.error && ( +

+ {roster.error} {roster.sessions.length > 0 && "Showing the previous snapshot."} +

+ )} + {roster.loading && ( + Checking subagents… + )} + {!roster.loading && !roster.error && roster.sessions.length === 0 && ( +

+ {roster.nextCursor + ? "No subagents found in the checked pages yet." + : "No saved subagents in this session."} +

+ )} +
    + {roster.sessions.map((session) => ( +
  • + +
  • + ))} +
+ {roster.nextCursor && ( + + )} +

+ Idle does not confirm completion. Open a child to view its conversation and any recovery + prompt. +

+
+
+ ) +} diff --git a/apps/desktop/src/renderer/hooks/use-subagent-roster.test.tsx b/apps/desktop/src/renderer/hooks/use-subagent-roster.test.tsx new file mode 100644 index 00000000..65e9cdd4 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/use-subagent-roster.test.tsx @@ -0,0 +1,209 @@ +import { afterEach, describe, expect, mock, test } from "bun:test" +import type { NativeSession, SessionRosterPage } from "@devo-ai/sdk/v2/client" +import { Window } from "happy-dom" +import { act } from "react" +import type { Root } from "react-dom/client" + +const dom = new Window({ url: "http://localhost" }) +Object.assign(globalThis, { + window: dom, + document: dom.document, + navigator: dom.navigator, + HTMLElement: dom.HTMLElement, + IS_REACT_ACT_ENVIRONMENT: true, +}) +let read = async (_params: unknown): Promise => ({ + data: [], + nextCursor: null, +}) +let connected = true +mock.module("../services/connection-manager", () => ({ + getProjectClient: () => + connected ? { session: { roster: (params: unknown) => read(params) } } : null, +})) +const { readSubagentRoster, useSubagentRoster } = await import("./use-subagent-roster") +const { createRoot } = await import("react-dom/client") + +function child(id: string, parentId = "parent"): NativeSession { + return { + id, + parent: { kind: "agent", sessionId: parentId }, + ephemeral: false, + } as NativeSession +} +function deferred() { + let resolve!: (value: T) => void + let reject!: (cause: Error) => void + const promise = new Promise((yes, no) => { + resolve = yes + reject = no + }) + return { promise, resolve, reject } +} +let root: Root | undefined +let mount: HTMLElement +let state: ReturnType +function Harness({ id, open }: { id: string; open: boolean }) { + state = useSubagentRoster(id, "/repo", open) + return
{state.sessions.map((session) => session.id).join(",")}
+} +async function render(id = "parent", open = true) { + if (!root) { + mount = document.createElement("div") + document.body.append(mount) + root = createRoot(mount) + } + await act(async () => { + root?.render() + }) +} +afterEach(async () => { + await act(async () => root?.unmount()) + root = undefined + mount?.remove() + connected = true + read = async () => ({ data: [], nextCursor: null }) +}) + +describe("bounded child roster scan", () => { + test("finds children on later pages, excluding roots, forks, grandchildren and ephemeral children", async () => { + const calls: Array = [] + const result = await readSubagentRoster( + async (cursor) => { + calls.push(cursor) + return cursor + ? { + data: [child("right"), child("grandchild", "right")], + nextCursor: null, + } + : { + data: [ + { id: "root" }, + { id: "fork", forkFromId: "parent" }, + { ...child("side"), ephemeral: true }, + child("other", "elsewhere"), + ] as NativeSession[], + nextCursor: "4", + } + }, + "parent", + new AbortController().signal, + ) + expect(result).toEqual({ + sessions: [child("right")], + nextCursor: null, + scanned: 6, + }) + expect(calls).toEqual([undefined, "4"]) + }) + test("caps each scan at five pages and continues explicitly without duplicates", async () => { + const read = async (cursor?: string) => ({ + data: [child("same")], + nextCursor: String(Number(cursor ?? 0) + 1), + }) + const signal = new AbortController().signal + const first = await readSubagentRoster(read, "parent", signal) + expect(first).toEqual({ + sessions: [child("same")], + nextCursor: "5", + scanned: 5, + }) + expect(await readSubagentRoster(read, "parent", signal, first)).toEqual({ + sessions: [child("same")], + nextCursor: "10", + scanned: 10, + }) + }) + test("rejects repeated cursors rather than silently claiming a complete roster", async () => { + await expect( + readSubagentRoster( + async () => ({ data: [], nextCursor: "same" }), + "parent", + new AbortController().signal, + ), + ).rejects.toThrow("repeated a session page") + }) + test("aborting between pages prevents further reads", async () => { + const controller = new AbortController() + let calls = 0 + await expect( + readSubagentRoster( + async () => { + calls++ + controller.abort() + return { data: [], nextCursor: "more" } + }, + "parent", + controller.signal, + ), + ).rejects.toThrow() + expect(calls).toBe(1) + }) +}) + +describe("roster lifecycle", () => { + test("closed panels make no reads; repeated refreshes share the active scan", async () => { + const pending = deferred() + let calls = 0 + read = async () => { + calls++ + return pending.promise + } + await render("parent", false) + expect(calls).toBe(0) + await render() + await act(async () => { + void state.refresh() + void state.refresh() + }) + expect(calls).toBe(1) + await act(async () => pending.resolve({ data: [child("one")], nextCursor: null })) + expect(state.sessions).toEqual([child("one")]) + }) + test("closing and reopening ignores an old in-flight response", async () => { + const old = deferred() + read = async () => old.promise + await render() + await render("parent", false) + read = async () => ({ data: [child("new")], nextCursor: null }) + await render() + await act(async () => old.resolve({ data: [child("old")], nextCursor: null })) + expect(state.sessions).toEqual([child("new")]) + expect(state.loading).toBe(false) + }) + test("navigation and unmount discard obsolete scans", async () => { + const old = deferred() + read = async () => old.promise + await render() + read = async () => ({ data: [child("new", "other")], nextCursor: null }) + await render("other") + await act(async () => old.resolve({ data: [child("old")], nextCursor: null })) + expect(state.sessions).toEqual([child("new", "other")]) + await act(async () => root?.unmount()) + root = undefined + }) + test("refresh errors retain the previous snapshot and allow retry", async () => { + read = async () => ({ data: [child("one")], nextCursor: null }) + await render() + read = async () => { + throw new Error("offline") + } + await act(async () => state.refresh()) + expect({ + sessions: state.sessions, + error: state.error, + loading: state.loading, + }).toEqual({ sessions: [child("one")], error: "offline", loading: false }) + read = async () => ({ data: [], nextCursor: null }) + await act(async () => state.refresh()) + expect({ sessions: state.sessions, error: state.error }).toEqual({ + sessions: [], + error: null, + }) + }) + test("disconnection is an explicit error, not an empty success", async () => { + connected = false + await render() + expect(state.error).toBe("Connect to the runtime to view subagents.") + }) +}) diff --git a/apps/desktop/src/renderer/hooks/use-subagent-roster.ts b/apps/desktop/src/renderer/hooks/use-subagent-roster.ts new file mode 100644 index 00000000..a96dd177 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/use-subagent-roster.ts @@ -0,0 +1,113 @@ +import type { NativeSession, SessionRosterPage } from "@devo-ai/sdk/v2/client" +import { useCallback, useEffect, useRef, useState } from "react" +import { getProjectClient } from "../services/connection-manager" + +export interface SubagentRoster { + sessions: NativeSession[] + nextCursor: string | null + scanned: number +} + +const emptyRoster: SubagentRoster = { + sessions: [], + nextCursor: null, + scanned: 0, +} + +/** Bound each user-requested scan; further pages require an explicit Load more. */ +export async function readSubagentRoster( + read: (cursor?: string) => Promise, + parentId: string, + signal: AbortSignal, + previous: SubagentRoster = emptyRoster, +): Promise { + const sessions = new Map(previous.sessions.map((session) => [session.id, session])) + let cursor = previous.nextCursor ?? undefined + let scanned = previous.scanned + const seen = new Set() + for (let page = 0; page < 5; page++) { + signal.throwIfAborted() + if (cursor) seen.add(cursor) + const result = await read(cursor) + signal.throwIfAborted() + for (const session of result.data) { + // User forks and independent roots are not subagents. Ephemeral side + // questions do not have a durable transcript to navigate to. + if (session.parent?.sessionId === parentId && !session.ephemeral) { + sessions.set(session.id, session) + } + } + scanned += result.data.length + cursor = result.nextCursor ?? undefined + if (!cursor) break + if (seen.has(cursor)) + throw new Error("The server repeated a session page. Refresh to try again.") + } + return { + sessions: [...sessions.values()], + nextCursor: cursor ?? null, + scanned, + } +} + +/** On-demand only: no closed-panel polling, child hydration, or model work. */ +export function useSubagentRoster(sessionId: string, directory: string, open: boolean) { + const [roster, setRoster] = useState(emptyRoster) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const request = useRef(null) + const run = useCallback( + async (previous = emptyRoster) => { + if (request.current) return + const controller = new AbortController() + request.current = controller + setLoading(true) + setError(null) + try { + const client = getProjectClient(directory) + if (!client) throw new Error("Connect to the runtime to view subagents.") + const next = await readSubagentRoster( + (cursor) => + client.session.roster({ + cwds: [], + includeChildren: true, + limit: 200, + ...(cursor ? { cursor } : {}), + }), + sessionId, + controller.signal, + previous, + ) + if (!controller.signal.aborted) setRoster(next) + } catch (cause) { + if (!controller.signal.aborted) + setError(cause instanceof Error ? cause.message : String(cause)) + } finally { + if (request.current === controller) { + request.current = null + setLoading(false) + } + } + }, + [directory, sessionId], + ) + + useEffect(() => { + setRoster(emptyRoster) + setError(null) + setLoading(false) + if (open) void run() + return () => { + request.current?.abort() + request.current = null + } + }, [open, run]) + + return { + ...roster, + loading, + error, + refresh: () => run(), + loadMore: () => run(roster), + } +} diff --git a/docs/desktop-subagent-roster.md b/docs/desktop-subagent-roster.md new file mode 100644 index 00000000..4dc08ff7 --- /dev/null +++ b/docs/desktop-subagent-roster.md @@ -0,0 +1,61 @@ +# Desktop subagent roster + +The session header's **Subagents** button opens an on-demand snapshot of its +saved direct children. Select a child to open its existing conversation. The +existing parent breadcrumb returns to the parent, and a child's own roster can +be used to inspect the next level. User forks and independent roots remain +normal top-level conversations; transient `/side` agents are excluded. + +## Why this slice exists + +Desktop intentionally hides child sessions from its main sidebar. It previously +had no child roster, and an unknown session ID was resolved by scanning the +default, root-only `session/list`. That lookup could not find a child after a +restart or missed creation event. Unknown IDs now use canonical `session/read`, +without resuming an actor or loading its history just to resolve the identity. + +The roster uses the existing Native `session/list` contract with +`includeChildren: true`. It retains canonical status and flags instead of +projecting a second subagent lifecycle. Approval and input flags take precedence +over working; queued idle work is labeled **Queued**. **Idle** means no active +turn, not successful task completion or a promise that saved work will resume. +Opening the child lets the existing conversation/recovery UI show more detail. + +## Refresh and cost + +- Open, Refresh, and Check more are read-only. They do not spawn agents, send + messages, replay tools, or resume work. There is no closed-panel polling. +- Each scan requests at most five pages of 200 sessions, retaining only direct + children. If a cursor remains, the UI exposes **Check more sessions** rather + than presenting a complete empty result. Duplicate child IDs are deduplicated. +- The Native list API has no parent filter. Pages enumerate the global roster so + children whose cwd changed are not omitted. The five-page bound limits returned + rows and round trips, not the server's underlying index/live-handle enumeration. + Parent-filtered listing or a dedicated subscription would be needed for an + efficient continuously live view at large scale. +- Refresh failures preserve the previous snapshot with an explicit error. + Closing or switching the session invalidates pending results and stops further + page requests. A request already sent to the transport may still finish. +- Native cursor pages are not a transactional snapshot. Concurrent inserts or + deletion can shift pagination; Refresh starts a new scan. No completeness + guarantee across concurrent mutations is claimed. + +## Relation to the Python controls + +PR #272 adds Python `rlm.collect`, progress notes, graceful end-turn, and independent +root creation. Those are not new public Native UI RPCs. This Desktop change works +on the existing Native surface and does not stack or duplicate #272's runtime +changes. It does not expose collection results, retained final answers, child +progress notes, or bulk steering/cancellation. Those require a separate design +that preserves the runtime's recovery and admission semantics. + +## Verification + +Tests cover exact-ID child discovery, Native validation, read-only requests, +multi-page filtering, bounded scans, duplicate IDs, repeated cursor errors, +close/reopen and navigation races, repeated refreshes, error/retry behavior, and +real Base UI popover opening, Escape/Close dismissal, focus restoration and child +navigation. The roster does not change the existing visual tokens or icon scale. + +Packaged Electron, visual screenshot and real-provider end-to-end checks remain +separate from these component and transport tests.