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.
This commit is contained in:
Simon Klee
2026-10-26 16:39:56 +01:00
Unverified
parent 0bf52eea82
commit e16b5f6858
5 changed files with 76 additions and 87 deletions
+8 -16
View File
@@ -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(() => {
+1 -6
View File
@@ -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(() => {
+2 -14
View File
@@ -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) {
+49 -50
View File
@@ -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<OpenCodeEvent, { type: "form.created" }>["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) =>
+16 -1
View File
@@ -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