From e16b5f6858dec31ccc7be5ce7fab3ef0e927e67a Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Mon, 26 Oct 2026 16:39:56 +0100 Subject: [PATCH] feat(tui): read Session families in one request With the data layer's family read carrying every member's status, permissions, and forms, the TUI's own per-member reads are redundant. Started alongside the family read, they still went out as separate requests. The session route now awaits only the family read and no longer syncs pending requests for each descendant. Tab prefetch drops its permission and form reads. The open dialog reads one family per distinct root of running Sessions and of Sessions with pending requests, instead of two pending-request reads per running Session and a single read per root. Opening a root with subagents now costs /session/active once per connection and one family read. --- packages/tui/src/component/dialog-open.tsx | 24 ++--- packages/tui/src/context/session-tabs.tsx | 7 +- packages/tui/src/routes/session/index.tsx | 16 +-- .../tui/test/cli/tui/dialog-open.test.tsx | 99 +++++++++---------- packages/tui/test/completion-notices.test.tsx | 17 +++- 5 files changed, 76 insertions(+), 87 deletions(-) diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index f6d6412c188..e90a72bb338 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -176,22 +176,14 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: createEffect(() => { const knownRoots = new Set(props.sessions.map((session) => session.id)) - const active = data.session.active() - active.forEach((id) => { - void data.session.permission.sync(id).catch(() => undefined) - void data.session.form.sync(id).catch(() => undefined) - }) - new Set( - [ - ...data.session.permission.sessions(), - ...data.session.form.sessions(), - ...(recent() === true ? active : []), - ].map((id) => data.session.root(id)), - ).forEach((rootID) => { - if (!knownRoots.has(rootID) && !data.session.get(rootID)) { - void data.session.sync(rootID).catch(() => undefined) - } - }) + // A family read loads the root and every member's pending requests. Any member's ID reads the same family, so an + // unloaded Session resolves to itself. + new Set([ + ...data.session.active().map((id) => data.session.root(id)), + ...[...data.session.permission.sessions(), ...data.session.form.sessions()] + .map((id) => data.session.root(id)) + .filter((rootID) => !knownRoots.has(rootID) && !data.session.get(rootID)), + ]).forEach((rootID) => void data.session.sync(rootID, { children: true }).catch(() => undefined)) }) const options = createMemo(() => { diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 45265f4e7c0..1d7808ffea0 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -310,12 +310,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp .filter((sessionID) => sessionID !== current()) for (const sessionID of sessions) { if (stale) return - await Promise.allSettled([ - data.session.message.sync(sessionID), - data.session.pending.sync(sessionID), - data.session.permission.sync(sessionID), - data.session.form.sync(sessionID), - ]) + await Promise.allSettled([data.session.message.sync(sessionID), data.session.pending.sync(sessionID)]) } }, TAB_PREFETCH_DELAY) onCleanup(() => { diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 8a5f1e987bc..e0ff63a292e 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -325,15 +325,6 @@ export function Session(props: { ), ) - createEffect( - on([descendantSessionIDs, () => client.connection.status()], ([sessionIDs, status]) => { - if (status !== "connected") return - void Promise.allSettled( - sessionIDs.flatMap((sessionID) => [data.session.permission.sync(sessionID), data.session.form.sync(sessionID)]), - ) - }), - ) - createEffect(() => { if (client.connection.status() !== "connected") return setSynced(false) @@ -342,11 +333,8 @@ export function Session(props: { let superseded = false onCleanup(() => (superseded = true)) void (async () => { - await Promise.all([ - data.session.sync(sessionID, { children: true }), - data.session.permission.sync(sessionID).catch(() => undefined), - data.session.form.sync(sessionID).catch(() => undefined), - ]) + // The family read also loads every member's status, permissions and forms. + await data.session.sync(sessionID, { children: true }) if (superseded) return const info = data.session.get(sessionID) if (!info) { diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index e22d57b5dea..cab92534fd9 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -5,7 +5,7 @@ import { CliRenderEvents, TextAttributes } from "@opentui/core" import path from "path" import { testRender } from "@opentui/solid" import { createSignal, onMount } from "solid-js" -import type { SessionInfo } from "@opencode/client" +import type { OpenCodeEvent, SessionInfo } from "@opencode/client" import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open" import { ConfigProvider } from "../../../src/config" import { ClientProvider } from "../../../src/context/client" @@ -854,28 +854,56 @@ test("option arrows stay in the only visible section", async () => { test("surfaces sessions awaiting permissions or questions at the top with attention icons", async () => { const remote = { directory: "/tmp/opencode/remote" } + const roots = [ + ...Array.from({ length: 9 }, (_, index) => ({ + ...recentSession, + id: `ses_recent_${index + 1}`, + title: `Recent session ${index + 1}`, + time: { created: 1, updated: 100 - index }, + })), + { + ...recentSession, + id: "ses_parent_question", + title: "Parent awaiting question", + location: remote, + time: { created: 1, updated: 5 }, + }, + { + ...recentSession, + id: "ses_blocked_permission", + title: "Old session awaiting permission", + time: { created: 1, updated: 1 }, + }, + ].map((session) => + ["ses_recent_1", "ses_blocked_permission"].includes(session.id) + ? { ...session, status: "running" as const } + : session, + ) + const child = { + ...recentSession, + id: "ses_child_question", + parentID: "ses_parent_question", + title: "Child subagent", + location: remote, + time: { created: 1, updated: 2 }, + } + const question: Extract["data"]["form"] = { + id: "frm_question", + sessionID: child.id, + title: "Questions", + fields: [{ key: "q0", type: "string", title: "Target" }], + } const fixture = await renderOpen((url) => { if (url.pathname === "/api/session/active") { return json({ data: { ses_recent_1: { type: "running" }, ses_blocked_permission: { type: "running" }, - ses_child_question: { type: "running" }, - }, - }) - } - if (url.pathname === "/api/session/ses_child_question") { - return json({ - data: { - ...recentSession, - id: "ses_child_question", - parentID: "ses_parent_question", - title: "Child subagent", - location: remote, - time: { created: 1, updated: 2 }, }, }) } + const info = [...roots, child].find((session) => url.pathname === `/api/session/${session.id}`) + if (info) return json({ data: info }) if (url.pathname === "/api/session/ses_blocked_permission/permission") { return json({ data: [ @@ -888,45 +916,16 @@ test("surfaces sessions awaiting permissions or questions at the top with attent ], }) } - if (url.pathname === "/api/session/ses_child_question/form") { - return json({ - data: [ - { - id: "frm_question", - sessionID: "ses_child_question", - title: "Questions", - fields: [{ key: "q0", type: "string", title: "Target" }], - }, - ], - }) - } + if (url.pathname === "/api/session/ses_child_question/form") return json({ data: [question] }) if (url.pathname !== "/api/session") return undefined - return json({ - data: [ - ...Array.from({ length: 9 }, (_, index) => ({ - ...recentSession, - id: `ses_recent_${index + 1}`, - title: `Recent session ${index + 1}`, - time: { created: 1, updated: 100 - index }, - })), - { - ...recentSession, - id: "ses_parent_question", - title: "Parent awaiting question", - location: remote, - time: { created: 1, updated: 5 }, - }, - { - ...recentSession, - id: "ses_blocked_permission", - title: "Old session awaiting permission", - time: { created: 1, updated: 1 }, - }, - ], - cursor: {}, - }) + const parentID = url.searchParams.get("parentID") + if (parentID && parentID !== "null") return json({ data: parentID === child.parentID ? [child] : [], cursor: {} }) + return json({ data: roots, cursor: {} }) }) + // The idle child is unloaded and outside reconnect hydration, so only the dialog can read its family. + fixture.emit({ id: "evt_question", created: 2, type: "form.created", location: remote, data: { form: question } }) + try { const frame = await fixture.app.waitForFrame( (value) => diff --git a/packages/tui/test/completion-notices.test.tsx b/packages/tui/test/completion-notices.test.tsx index 4ebe6341808..9152085a71f 100644 --- a/packages/tui/test/completion-notices.test.tsx +++ b/packages/tui/test/completion-notices.test.tsx @@ -165,7 +165,16 @@ test.each([40, 120])("subagent completion notices navigate to the child session if (url.pathname.endsWith("/inbox") || url.pathname.endsWith("/permission")) return json({ data: [] }) return undefined }, createEventStream()) - const server = Bun.serve({ port: 0, idleTimeout: 0, fetch: (request) => calls.fetch(request) }) + // The fixture synthesizes the family from in-process reads, so only requests through the server reach this log. + const requests: string[] = [] + const server = Bun.serve({ + port: 0, + idleTimeout: 0, + fetch: (request) => { + requests.push(new URL(request.url).pathname) + return calls.fetch(request) + }, + }) const { run } = await import("../src/app") const task = Effect.runPromise( run({ @@ -189,6 +198,12 @@ test.each([40, 120])("subagent completion notices navigate to the child session const x = lines[y].indexOf("General finished") await setup.mockMouse.click(x + 1, y) await setup.waitForFrame((frame) => frame.includes("Investigate authentication")) + // One family read loads both members, their pending requests, and serves the child's route. + expect( + requests + .filter((pathname) => /^\/api\/session\/(active|[^/]+\/(family|permission|form))$/.test(pathname)) + .toSorted(), + ).toEqual(["/api/session/active", `/api/session/${parent.id}/family`]) } finally { setup.renderer.destroy() await task