From cff66dfb69f277a0ee982f462665a6dfa5677a4e Mon Sep 17 00:00:00 2001 From: diamondplated Date: Sun, 29 Mar 2026 23:33:05 -0500 Subject: [PATCH] fix(tui): isolate subagent prompt routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../src/cli/cmd/tui/routes/session/index.tsx | 72 +++++++++++-------- .../cli/cmd/tui/routes/session/navigation.ts | 26 +++++++ .../test/cli/tui/session-navigation.test.ts | 30 ++++++++ 3 files changed, 99 insertions(+), 29 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts create mode 100644 packages/opencode/test/cli/tui/session-navigation.test.ts diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index fb62de9acf5f..d26706cf9687 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -80,6 +80,7 @@ import { DialogExportOptions } from "../../ui/dialog-export-options" import { formatTranscript } from "../../util/transcript" import { UI } from "@/cli/ui.ts" import { useTuiConfig } from "../../context/tui-config" +import { getDirectChildSessions, getSiblingSessions, shouldRenderSessionPrompt } from "./navigation" addDefaultParsers(parsers.parsers) @@ -121,20 +122,16 @@ export function Session() { const { theme } = useTheme() const promptRef = usePromptRef() const session = createMemo(() => sync.session.get(route.sessionID)) - const children = createMemo(() => { - const parentID = session()?.parentID ?? session()?.id - return sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) - .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) - }) + const directChildren = createMemo(() => getDirectChildSessions(sync.data.session, session()?.id)) + const siblingSessions = createMemo(() => getSiblingSessions(sync.data.session, session())) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) const permissions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.permission[x.id] ?? []) + return directChildren().flatMap((x) => sync.data.permission[x.id] ?? []) }) const questions = createMemo(() => { if (session()?.parentID) return [] - return children().flatMap((x) => sync.data.question[x.id] ?? []) + return directChildren().flatMap((x) => sync.data.question[x.id] ?? []) }) const pending = createMemo(() => { @@ -320,8 +317,7 @@ export function Session() { const local = useLocal() function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) + const next = directChildren()[0] if (next) { navigate({ type: "session", @@ -331,9 +327,8 @@ export function Session() { } function moveChild(direction: number) { - if (children().length === 1) return - - const sessions = children().filter((x) => !!x.parentID) + const sessions = siblingSessions() + if (sessions.length <= 1) return let next = sessions.findIndex((x) => x.id === session()?.id) - direction if (next >= sessions.length) next = 0 @@ -967,6 +962,19 @@ export function Session() { }, ]) + createEffect(() => { + if ( + shouldRenderSessionPrompt({ + session: session(), + permissionCount: permissions().length, + questionCount: questions().length, + }) + ) { + return + } + promptRef.set(undefined) + }) + const revertInfo = createMemo(() => session()?.revert) const revertMessageID = createMemo(() => revertInfo()?.messageID) @@ -1162,22 +1170,28 @@ export function Session() { - { - prompt = r - promptRef.set(r) - // Apply initial prompt when prompt component mounts (e.g., from fork) - if (route.initialPrompt) { - r.set(route.initialPrompt) - } - }} - disabled={permissions().length > 0 || questions().length > 0} - onSubmit={() => { - toBottom() - }} - sessionID={route.sessionID} - /> + + { + prompt = r + promptRef.set(r) + if (route.initialPrompt) { + r.set(route.initialPrompt) + } + }} + disabled={permissions().length > 0 || questions().length > 0} + onSubmit={() => { + toBottom() + }} + sessionID={route.sessionID} + /> + diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts b/packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts new file mode 100644 index 000000000000..ae37002cdc3b --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/routes/session/navigation.ts @@ -0,0 +1,26 @@ +type SessionNode = { + id: string + parentID?: string | null +} + +function byID(a: SessionNode, b: SessionNode) { + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} + +export function getDirectChildSessions(sessions: readonly T[], sessionID?: string): T[] { + if (!sessionID) return [] + return sessions.filter((session) => session.parentID === sessionID).toSorted(byID) +} + +export function getSiblingSessions(sessions: readonly T[], session?: T): T[] { + if (!session?.parentID) return [] + return sessions.filter((candidate) => candidate.parentID === session.parentID).toSorted(byID) +} + +export function shouldRenderSessionPrompt(input: { + session?: SessionNode + permissionCount: number + questionCount: number +}) { + return !input.session?.parentID && input.permissionCount === 0 && input.questionCount === 0 +} diff --git a/packages/opencode/test/cli/tui/session-navigation.test.ts b/packages/opencode/test/cli/tui/session-navigation.test.ts new file mode 100644 index 000000000000..3fc2f1b38b7f --- /dev/null +++ b/packages/opencode/test/cli/tui/session-navigation.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" + +const { getDirectChildSessions, getSiblingSessions, shouldRenderSessionPrompt } = await import( + "../../../src/cli/cmd/tui/routes/session/navigation" +) + +describe("session navigation helpers", () => { + const sessions = [ + { id: "root" }, + { id: "child-a", parentID: "root" }, + { id: "child-b", parentID: "root" }, + { id: "grandchild-a", parentID: "child-a" }, + ] + + test("collects only direct children for the active session", () => { + expect(getDirectChildSessions(sessions, "root").map((session) => session.id)).toEqual(["child-a", "child-b"]) + expect(getDirectChildSessions(sessions, "child-a").map((session) => session.id)).toEqual(["grandchild-a"]) + }) + + test("collects sibling sessions for subagent cycling", () => { + expect(getSiblingSessions(sessions, sessions[1]).map((session) => session.id)).toEqual(["child-a", "child-b"]) + expect(getSiblingSessions(sessions, sessions[0])).toEqual([]) + }) + + test("only renders the prompt for root sessions without blocking UI", () => { + expect(shouldRenderSessionPrompt({ session: sessions[0], permissionCount: 0, questionCount: 0 })).toBe(true) + expect(shouldRenderSessionPrompt({ session: sessions[1], permissionCount: 0, questionCount: 0 })).toBe(false) + expect(shouldRenderSessionPrompt({ session: sessions[0], permissionCount: 1, questionCount: 0 })).toBe(false) + }) +})