From 1c66d8911c2ac3788716ebaf1f084bfd217f3733 Mon Sep 17 00:00:00 2001 From: aryaminus Date: Tue, 7 Jul 2026 09:44:56 -0700 Subject: [PATCH] fix(tui): restore subagent navigation --- .../opencode/src/cli/cmd/run/footer.view.tsx | 2 +- .../test/cli/run/footer.view.test.tsx | 31 +++++++++++++++++-- .../src/components/message-part-task.ts | 7 +++++ .../src/components/message-part.test.ts | 15 +++++++++ .../src/components/message-part.tsx | 6 ++-- packages/tui/src/routes/session/index.tsx | 23 ++++++++++---- 6 files changed, 72 insertions(+), 12 deletions(-) create mode 100644 packages/session-ui/src/components/message-part-task.ts diff --git a/packages/opencode/src/cli/cmd/run/footer.view.tsx b/packages/opencode/src/cli/cmd/run/footer.view.tsx index 4b4c00f9a89e..49bec2ac1c52 100644 --- a/packages/opencode/src/cli/cmd/run/footer.view.tsx +++ b/packages/opencode/src/cli/cmd/run/footer.view.tsx @@ -466,7 +466,7 @@ export function RunFooterView(props: RunFooterViewProps) { if (queuedPrompts().length > 0 && queuedShortcut()) { items.push({ kind: "queued", key: queuedShortcut(), label: `${queue()} queued` }) } - if (activeTabs().length > 0 && subagentShortcut()) { + if (tabs().length > 0 && subagentShortcut()) { items.push({ kind: "subagents", key: subagentShortcut(), label: "subagents" }) } diff --git a/packages/opencode/test/cli/run/footer.view.test.tsx b/packages/opencode/test/cli/run/footer.view.test.tsx index 2e9fd8ef1767..2130ac4a457b 100644 --- a/packages/opencode/test/cli/run/footer.view.test.tsx +++ b/packages/opencode/test/cli/run/footer.view.test.tsx @@ -1052,7 +1052,7 @@ test("direct footer separates a lone context hint from model and command hint", } }) -test("direct footer hides the subagent hint when only completed subagents remain", async () => { +test("direct footer keeps the subagent hint when only completed subagents remain", async () => { const app = await renderFooter({ providers: [provider()], currentModel: { providerID: "opencode", modelID: "gpt-5" }, @@ -1072,8 +1072,33 @@ test("direct footer hides the subagent hint when only completed subagents remain const frame = app.captureCharFrame() expect(frame).toContain("GPT-5") - expect(frame).toContain("xhigh · ctrl+p cmd") - expect(frame).not.toContain("ctrl+x down subagents") + expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd") + } finally { + app.cleanup() + } +}) + +test("direct footer keeps the subagent hint when only cancelled subagents remain", async () => { + const app = await renderFooter({ + providers: [provider()], + currentModel: { providerID: "opencode", modelID: "gpt-5" }, + currentVariant: "xhigh", + subagents: { + tabs: [subagent({ sessionID: "s-1", label: "Explore", description: "Inspect auth flow", status: "cancelled" })], + details: {}, + permissions: [], + questions: [], + }, + backgroundSubagents: false, + width: 160, + }) + + try { + await app.renderOnce() + const frame = app.captureCharFrame() + + expect(frame).toContain("GPT-5") + expect(frame).toContain("xhigh · ctrl+x down subagents · ctrl+p cmd") } finally { app.cleanup() } diff --git a/packages/session-ui/src/components/message-part-task.ts b/packages/session-ui/src/components/message-part-task.ts new file mode 100644 index 000000000000..f4ea46050c4e --- /dev/null +++ b/packages/session-ui/src/components/message-part-task.ts @@ -0,0 +1,7 @@ +export function taskSessionIdFromMetadata( + stateMetadata: Record | undefined, + partMetadata: Record | undefined, +) { + const value = stateMetadata?.sessionId ?? stateMetadata?.sessionID ?? partMetadata?.sessionId ?? partMetadata?.sessionID + return typeof value === "string" && value ? value : undefined +} diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index 25dcbae6b404..72ce3ba85aef 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,6 +1,21 @@ import { describe, expect, test } from "bun:test" +import { taskSessionIdFromMetadata } from "./message-part-task" import { readPartText } from "./message-part-text" +describe("taskSessionIdFromMetadata", () => { + test("uses state metadata when available", () => { + expect(taskSessionIdFromMetadata({ sessionId: "child-1" }, { sessionId: "child-2" })).toBe("child-1") + }) + + test("falls back to top-level part metadata for cancelled task cards", () => { + expect(taskSessionIdFromMetadata(undefined, { sessionId: "child-1" })).toBe("child-1") + }) + + test("accepts legacy sessionID casing", () => { + expect(taskSessionIdFromMetadata({}, { sessionID: "child-1" })).toBe("child-1") + }) +}) + describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { expect(readPartText(undefined, { id: "part_1" })).toBe("") diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 098ecdbd62a7..702c76209e74 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -62,6 +62,7 @@ import { animate } from "motion" import { useLocation } from "@solidjs/router" import { attached, inline, kind } from "./message-file" import { readPartText } from "./message-part-text" +import { taskSessionIdFromMetadata } from "./message-part-task" import { SessionProgressIndicatorV2 } from "../v2/components/session-progress-indicator-v2" async function writeClipboard(text: string): Promise { @@ -1501,10 +1502,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const input = () => part().state?.input ?? emptyInput // @ts-expect-error const partMetadata = () => part().state?.metadata ?? emptyMetadata + const topLevelPartMetadata = () => + ("metadata" in part() ? part().metadata : undefined) as Record | undefined const taskId = createMemo(() => { if (part().tool !== "task") return - const value = partMetadata().sessionId - if (typeof value === "string" && value) return value + return taskSessionIdFromMetadata(partMetadata(), topLevelPartMetadata()) }) const taskHref = createMemo(() => { if (part().tool !== "task") return diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 6d77b0ea58fd..20776147be96 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -205,9 +205,17 @@ export function Session() { }) onCleanup(() => setEpilogue()) const children = createMemo(() => { - const parentID = session()?.parentID ?? session()?.id + const parentID = session()?.id + if (!parentID) return [] return sync.data.session - .filter((x) => x.parentID === parentID || x.id === parentID) + .filter((x) => x.parentID === parentID) + .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) + }) + const siblings = createMemo(() => { + const parentID = session()?.parentID + if (!parentID) return [] + return sync.data.session + .filter((x) => x.parentID === parentID) .toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)) }) const messages = createMemo(() => sync.data.message[route.sessionID] ?? []) @@ -432,15 +440,14 @@ export function Session() { } function moveFirstChild() { - if (children().length === 1) return - const next = children().find((x) => !!x.parentID) + const next = children()[0] if (next) enterChild(next.id) } function moveChild(direction: number) { - if (children().length === 1) return + const sessions = siblings() + if (sessions.length === 0) return - const sessions = children().filter((x) => !!x.parentID) let next = sessions.findIndex((x) => x.id === session()?.id) - direction if (next >= sessions.length) next = 0 @@ -1893,6 +1900,10 @@ function InlineTool(props: { onMouseUp={() => { if (renderer.getSelection()?.getSelectedText()) return if (failed()) { + if (props.onClick) { + props.onClick() + return + } setErrorExpanded((value) => !value) return }