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