From bcad7247c11163a161f3a52a542e0b80cd14bfb5 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 14:01:40 -0400 Subject: [PATCH 1/6] fix(session): tolerate malformed persisted parts --- packages/app/src/context/file/path.test.ts | 1 + packages/app/src/context/file/path.ts | 1 + .../app/src/pages/session/helpers.test.ts | 20 ++++++++++++++++++ packages/app/src/pages/session/helpers.ts | 6 +++--- .../src/components/message-part.test.ts | 21 +++++++++++++++++++ .../src/components/message-part.tsx | 4 ++-- 6 files changed, 48 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 99dd88ae0e..69043142b1 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -11,6 +11,7 @@ describe("file path helpers", () => { expect(path.tab("src/app.ts")).toBe("file://src/app.ts") expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts") expect(path.pathFromTab("other://src/app.ts")).toBeUndefined() + expect(path.pathFromTab(undefined as never)).toBeUndefined() }) test("normalizes Windows absolute paths with mixed separators", () => { diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 2bc4bde5e9..070907ec28 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -136,6 +136,7 @@ export function createPathHelpers(scope: () => string) { } const pathFromTab = (tabValue: string) => { + if (typeof tabValue !== "string") return if (!tabValue.startsWith("file://")) return return normalize(tabValue) } diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 64f77e59c5..02df366071 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -127,6 +127,26 @@ describe("createSessionTabs", () => { }) }) + test("ignores malformed persisted tab values", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["file://src/a.ts", undefined, { invalid: true }] as unknown as string[], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab), + }) + + expect(result.panelTabs()).toEqual(["norm:src/a.ts"]) + expect(result.activeTab()).toBe("norm:src/a.ts") + dispose() + }) + }) + test("prefers context and review fallbacks when no file tab is active", () => { createRoot((dispose) => { const [state] = createStore({ diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c3104919aa..421d06c1ec 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -49,9 +49,9 @@ export const createSessionTabs = (input: TabsInput) => { const panelTabs = createMemo( () => { const seen = new Set() - return input - .tabs() + return input.tabs() .all() + .filter((tab): tab is string => typeof tab === "string") .flatMap((tab) => { if (tab === "context" || tab === "review" || tab === "vault" || tab === "home" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector") return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] @@ -76,7 +76,7 @@ export const createSessionTabs = (input: TabsInput) => { if (active === "vault" && vaultOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (active === "review" && review()) return active - if (active && input.pathFromTab(active)) return input.normalizeTab(active) + if (typeof active === "string" && input.pathFromTab(active)) return input.normalizeTab(active) const first = openedTabs()[0] if (first) return first diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index f305d898ac..b5e67d4ec5 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { readPartText, settledChunkBoundary, splitSettledChunks } from "./message-part-text" import { shouldShowUserMessageText } from "./message-part-user" +import { amicodeReceiptCandidateKey } from "./message-part" describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { @@ -28,6 +29,26 @@ describe("readPartText", () => { }) }) +describe("amicodeReceiptCandidateKey", () => { + test("ignores a tool part without a string tool name", () => { + expect(() => amicodeReceiptCandidateKey({ type: "tool", tool: undefined } as never)).not.toThrow() + expect(amicodeReceiptCandidateKey({ type: "tool", tool: undefined } as never)).toEqual({}) + }) + + test("keeps valid completed receipt parts eligible", () => { + expect( + amicodeReceiptCandidateKey({ + type: "tool", + tool: "amicode_formulate", + state: { + status: "completed", + output: 'AMICODE_DIFF {"problem":"x","entity":"recommend","action":"accepted","seq":3}', + }, + } as never), + ).toEqual({ key: { problem: "x", entity: "recommend", action: "accepted" }, seq: 3 }) + }) +}) + describe("splitSettledChunks — heading-anchored segmentation", () => { test("no headings → single chunk (whole text) on completion", () => { diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 4d932c8014..020e23ab83 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -712,8 +712,8 @@ function index(items: readonly T[]) { // whose entity isn't inline-view-eligible — see receipt-runs.ts) are // candidates; everything else (still running, errored, not amicode_*, no/ // unparseable sentinel) gets `key: undefined` and can never merge. -function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { - if (!part || part.type !== "tool" || !part.tool.startsWith("amicode_")) return {} +export function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { + if (!part || part.type !== "tool" || typeof part.tool !== "string" || !part.tool.startsWith("amicode_")) return {} if (part.state.status !== "completed") return {} const sentinel = parseDiffSentinel(part.state.output) return { key: receiptRunKey(sentinel), seq: sentinel?.seq } From 0c064c9c0cd8c9873ce2a126e2fb756ee37a8c49 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 14:09:26 -0400 Subject: [PATCH 2/6] fix(session): harden handoff normalization --- packages/app/src/pages/session.tsx | 29 ++++++++-------- .../app/src/pages/session/helpers.test.ts | 33 +++++++++++++++++++ packages/app/src/pages/session/helpers.ts | 20 ++++++++++- .../src/components/message-part-receipts.ts | 11 +++++++ .../src/components/message-part.test.ts | 2 +- .../src/components/message-part.tsx | 11 +------ 6 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 packages/session-ui/src/components/message-part-receipts.ts diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 89ebdd021d..7768599b58 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -68,7 +68,15 @@ import { createSessionComposerRegionController, SessionComposerRegion, } from "@/pages/session/composer" -import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree, SESSION_PREVIEW_TAB } from "@/pages/session/helpers" +import { + createOpenReviewFile, + createSessionTabs, + createSizing, + normalizeSessionTab, + normalizeSessionTabs, + shouldShowFileTree, + SESSION_PREVIEW_TAB, +} from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" @@ -445,7 +453,7 @@ export default function Page() { if (current.all.length > 0 || current.active) return const all = normalizeTabs(from.all) - const active = from.active ? normalizeTab(from.active) : undefined + const active = normalizeTab(from.active) tabs().setAll(all) tabs().setActive(active && all.includes(active) ? active : all[0]) @@ -533,21 +541,12 @@ export default function Page() { return `calc(100% - ${layout.fileTree.width()}px)` }) - function normalizeTab(tab: string) { - if (!tab.startsWith("file://")) return tab - return file.tab(tab) + function normalizeTab(tab: unknown) { + return normalizeSessionTab(tab, file.tab) } - function normalizeTabs(list: string[]) { - const seen = new Set() - const next: string[] = [] - for (const item of list) { - const value = normalizeTab(item) - if (seen.has(value)) continue - seen.add(value) - next.push(value) - } - return next + function normalizeTabs(list: unknown) { + return normalizeSessionTabs(list, normalizeTab) } const openReviewPanel = () => { diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 02df366071..51d159b795 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -8,6 +8,8 @@ import { createSessionTabs, focusTerminalById, getTabReorderIndex, + normalizeSessionTab, + normalizeSessionTabs, shouldShowFileTree, } from "./helpers" import { closeSessionTab, openSessionTab } from "@/context/layout-tabs" @@ -19,6 +21,18 @@ describe("shouldShowFileTree", () => { }) }) +describe("normalizeSessionTabs", () => { + test("drops malformed values before file URL normalization", () => { + const normalizeFileTab = (tab: string) => `normalized:${tab}` + const normalizeTab = (tab: unknown) => normalizeSessionTab(tab, normalizeFileTab) + + expect(normalizeSessionTabs(["file://a.ts", undefined, { invalid: true }, "file://a.ts"], normalizeTab)).toEqual([ + "normalized:file://a.ts", + ]) + expect(normalizeSessionTab({ invalid: true }, normalizeFileTab)).toBeUndefined() + }) +}) + describe("createOpenReviewFile", () => { test("opens and loads selected review file", () => { const calls: string[] = [] @@ -147,6 +161,25 @@ describe("createSessionTabs", () => { }) }) + test("ignores a malformed active tab value", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: { invalid: true } as unknown as string | undefined, + all: ["file://src/a.ts"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab), + }) + + expect(result.activeTab()).toBe("norm:src/a.ts") + dispose() + }) + }) + test("prefers context and review fallbacks when no file tab is active", () => { createRoot((dispose) => { const [state] = createStore({ diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 421d06c1ec..c7cb697d2d 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -26,6 +26,23 @@ type TabsInput = { export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` +export function normalizeSessionTab(tab: unknown, normalizeFileTab: (tab: string) => string) { + if (typeof tab !== "string") return + if (!tab.startsWith("file://")) return tab + return normalizeFileTab(tab) +} + +export function normalizeSessionTabs(list: unknown, normalizeTab: (tab: unknown) => string | undefined) { + if (!Array.isArray(list)) return [] + const seen = new Set() + return list.flatMap((item) => { + const value = normalizeTab(item) + if (value === undefined || seen.has(value)) return [] + seen.add(value) + return [value] + }) +} + export function shouldShowFileTree(input: { visible: boolean; opened: boolean }) { return input.opened && input.visible } @@ -68,7 +85,8 @@ export const createSessionTabs = (input: TabsInput) => { equals: same, }) const activeTab = createMemo(() => { - const active = input.tabs().active() + const activeValue = input.tabs().active() + const active = typeof activeValue === "string" ? activeValue : undefined if (active === "home") return active if (active === "context") return active if (active === "pulseInspector") return active diff --git a/packages/session-ui/src/components/message-part-receipts.ts b/packages/session-ui/src/components/message-part-receipts.ts new file mode 100644 index 0000000000..3b89c265bf --- /dev/null +++ b/packages/session-ui/src/components/message-part-receipts.ts @@ -0,0 +1,11 @@ +import type { Part as PartType } from "@opencode-ai/sdk/v2" +import { parseDiffSentinel } from "@opencode-ai/ui/amicode-receipt" +import { receiptRunKey, type ReceiptKey } from "@opencode-ai/ui/amicode-receipt-runs" + +// Only completed amicode tool calls with a parseable receipt can be collapsed. +export function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { + if (!part || part.type !== "tool" || typeof part.tool !== "string" || !part.tool.startsWith("amicode_")) return {} + if (part.state.status !== "completed") return {} + const sentinel = parseDiffSentinel(part.state.output) + return { key: receiptRunKey(sentinel), seq: sentinel?.seq } +} diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index b5e67d4ec5..aa8bd2b952 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { readPartText, settledChunkBoundary, splitSettledChunks } from "./message-part-text" import { shouldShowUserMessageText } from "./message-part-user" -import { amicodeReceiptCandidateKey } from "./message-part" +import { amicodeReceiptCandidateKey } from "./message-part-receipts" describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 020e23ab83..de23268e82 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -694,14 +694,12 @@ import { type PartGroup, type PartRef, } from "./message-part-groups" -import { parseDiffSentinel } from "@opencode-ai/ui/amicode-receipt" import { editRowDiff, editRowFilePath, editRowLabel } from "@opencode-ai/ui/amicode-edit-row" import { collapseReceiptRuns, - receiptRunKey, type ReceiptCandidate, - type ReceiptKey, } from "@opencode-ai/ui/amicode-receipt-runs" +import { amicodeReceiptCandidateKey } from "./message-part-receipts" function index(items: readonly T[]) { return new Map(items.map((item) => [item.id, item] as const)) @@ -712,13 +710,6 @@ function index(items: readonly T[]) { // whose entity isn't inline-view-eligible — see receipt-runs.ts) are // candidates; everything else (still running, errored, not amicode_*, no/ // unparseable sentinel) gets `key: undefined` and can never merge. -export function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { - if (!part || part.type !== "tool" || typeof part.tool !== "string" || !part.tool.startsWith("amicode_")) return {} - if (part.state.status !== "completed") return {} - const sentinel = parseDiffSentinel(part.state.output) - return { key: receiptRunKey(sentinel), seq: sentinel?.seq } -} - function sameAmicodeCounts(a: Map, b: Map) { if (a === b) return true if (a.size !== b.size) return false From 2668630273685c8a30e3b28a2451e711374ab13b Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 14:14:02 -0400 Subject: [PATCH 3/6] fix(session-ui): isolate malformed tool guard --- .../message-part-receipt-guard.test.ts | 12 +++++++++++ .../components/message-part-receipt-guard.ts | 3 +++ .../src/components/message-part-receipts.ts | 3 ++- .../src/components/message-part.test.ts | 21 ------------------- 4 files changed, 17 insertions(+), 22 deletions(-) create mode 100644 packages/session-ui/src/components/message-part-receipt-guard.test.ts create mode 100644 packages/session-ui/src/components/message-part-receipt-guard.ts diff --git a/packages/session-ui/src/components/message-part-receipt-guard.test.ts b/packages/session-ui/src/components/message-part-receipt-guard.test.ts new file mode 100644 index 0000000000..621106e5eb --- /dev/null +++ b/packages/session-ui/src/components/message-part-receipt-guard.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from "bun:test" +import { hasStringToolName } from "./message-part-receipt-guard" + +describe("hasStringToolName", () => { + test("rejects a tool part without a string tool name", () => { + expect(hasStringToolName({ type: "tool", tool: undefined })).toBe(false) + }) + + test("accepts a tool part with a string tool name", () => { + expect(hasStringToolName({ type: "tool", tool: "amicode_formulate" })).toBe(true) + }) +}) diff --git a/packages/session-ui/src/components/message-part-receipt-guard.ts b/packages/session-ui/src/components/message-part-receipt-guard.ts new file mode 100644 index 0000000000..62bede1290 --- /dev/null +++ b/packages/session-ui/src/components/message-part-receipt-guard.ts @@ -0,0 +1,3 @@ +export function hasStringToolName(value: unknown): value is { tool: string } { + return typeof value === "object" && value !== null && "tool" in value && typeof value.tool === "string" +} diff --git a/packages/session-ui/src/components/message-part-receipts.ts b/packages/session-ui/src/components/message-part-receipts.ts index 3b89c265bf..654eb7ca7f 100644 --- a/packages/session-ui/src/components/message-part-receipts.ts +++ b/packages/session-ui/src/components/message-part-receipts.ts @@ -1,10 +1,11 @@ import type { Part as PartType } from "@opencode-ai/sdk/v2" import { parseDiffSentinel } from "@opencode-ai/ui/amicode-receipt" import { receiptRunKey, type ReceiptKey } from "@opencode-ai/ui/amicode-receipt-runs" +import { hasStringToolName } from "./message-part-receipt-guard" // Only completed amicode tool calls with a parseable receipt can be collapsed. export function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { - if (!part || part.type !== "tool" || typeof part.tool !== "string" || !part.tool.startsWith("amicode_")) return {} + if (!part || part.type !== "tool" || !hasStringToolName(part) || !part.tool.startsWith("amicode_")) return {} if (part.state.status !== "completed") return {} const sentinel = parseDiffSentinel(part.state.output) return { key: receiptRunKey(sentinel), seq: sentinel?.seq } diff --git a/packages/session-ui/src/components/message-part.test.ts b/packages/session-ui/src/components/message-part.test.ts index aa8bd2b952..f305d898ac 100644 --- a/packages/session-ui/src/components/message-part.test.ts +++ b/packages/session-ui/src/components/message-part.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { readPartText, settledChunkBoundary, splitSettledChunks } from "./message-part-text" import { shouldShowUserMessageText } from "./message-part-user" -import { amicodeReceiptCandidateKey } from "./message-part-receipts" describe("readPartText", () => { test("returns empty string when accum is undefined and part text is undefined", () => { @@ -29,26 +28,6 @@ describe("readPartText", () => { }) }) -describe("amicodeReceiptCandidateKey", () => { - test("ignores a tool part without a string tool name", () => { - expect(() => amicodeReceiptCandidateKey({ type: "tool", tool: undefined } as never)).not.toThrow() - expect(amicodeReceiptCandidateKey({ type: "tool", tool: undefined } as never)).toEqual({}) - }) - - test("keeps valid completed receipt parts eligible", () => { - expect( - amicodeReceiptCandidateKey({ - type: "tool", - tool: "amicode_formulate", - state: { - status: "completed", - output: 'AMICODE_DIFF {"problem":"x","entity":"recommend","action":"accepted","seq":3}', - }, - } as never), - ).toEqual({ key: { problem: "x", entity: "recommend", action: "accepted" }, seq: 3 }) - }) -}) - describe("splitSettledChunks — heading-anchored segmentation", () => { test("no headings → single chunk (whole text) on completion", () => { From b321107ab82cb256ed53835e0043569b49d8acb2 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 14:19:31 -0400 Subject: [PATCH 4/6] fix: type session tab normalization boundary --- .../app/src/pages/session/helpers.test.ts | 20 +++++++++++++++++++ packages/app/src/pages/session/helpers.ts | 4 ++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 51d159b795..08d1a0d28b 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -180,6 +180,26 @@ describe("createSessionTabs", () => { }) }) + test("ignores a tab rejected by normalization", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: "file://src/a.ts" as string | undefined, + all: ["file://src/a.ts"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => tab.slice("file://".length), + normalizeTab: () => undefined, + }) + + expect(result.panelTabs()).toEqual([]) + expect(result.activeTab()).toBe("home") + dispose() + }) + }) + test("prefers context and review fallbacks when no file tab is active", () => { createRoot((dispose) => { const [state] = createStore({ diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index c7cb697d2d..ba3754b007 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -16,7 +16,7 @@ type Tabs = { type TabsInput = { tabs: Accessor pathFromTab: (tab: string) => string | undefined - normalizeTab: (tab: string) => string + normalizeTab: (tab: string) => string | undefined review?: Accessor hasReview?: Accessor /** the Vault tab (amicode) — a named surface like "context", never a file */ @@ -73,7 +73,7 @@ export const createSessionTabs = (input: TabsInput) => { if (tab === "context" || tab === "review" || tab === "vault" || tab === "home" || tab === SESSION_PREVIEW_TAB || tab === "pulseInspector") return [] if (tab === SESSION_OPEN_FILE_TAB && !fileBrowser()) return [] const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab - if (seen.has(value)) return [] + if (value === undefined || seen.has(value)) return [] seen.add(value) return [value] }) From 65bf1250dd0fd98f4b17f6284bd617283edcc8b8 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 14:25:25 -0400 Subject: [PATCH 5/6] fix: fall back after tab normalization rejection --- packages/app/src/pages/session/helpers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index ba3754b007..5cbd48ba32 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -94,7 +94,10 @@ export const createSessionTabs = (input: TabsInput) => { if (active === "vault" && vaultOpen()) return active if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active if (active === "review" && review()) return active - if (typeof active === "string" && input.pathFromTab(active)) return input.normalizeTab(active) + if (typeof active === "string" && input.pathFromTab(active)) { + const normalized = input.normalizeTab(active) + if (normalized !== undefined) return normalized + } const first = openedTabs()[0] if (first) return first From 599566fd5451798d28e672cf4bbb4a618d92f060 Mon Sep 17 00:00:00 2001 From: Jack Champagne Date: Thu, 10 Sep 2026 15:08:55 -0400 Subject: [PATCH 6/6] fix(session-ui): guard malformed tool brain refs --- .../components/message-part-brain-ref.test.ts | 18 ++++++++++++++++++ .../src/components/message-part-brain-ref.ts | 7 +++++++ .../session-ui/src/components/message-part.tsx | 11 ++++++----- 3 files changed, 31 insertions(+), 5 deletions(-) create mode 100644 packages/session-ui/src/components/message-part-brain-ref.test.ts create mode 100644 packages/session-ui/src/components/message-part-brain-ref.ts diff --git a/packages/session-ui/src/components/message-part-brain-ref.test.ts b/packages/session-ui/src/components/message-part-brain-ref.test.ts new file mode 100644 index 0000000000..3aef6618f3 --- /dev/null +++ b/packages/session-ui/src/components/message-part-brain-ref.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { messagePartBrainRef } from "./message-part-brain-ref" + +describe("messagePartBrainRef", () => { + test("does not pass a malformed tool part to the brain reference mapper", () => { + expect(() => messagePartBrainRef({ type: "tool", tool: undefined })).not.toThrow() + expect(messagePartBrainRef({ type: "tool", tool: undefined })).toBeUndefined() + }) + + test("preserves brain references for valid tool parts", () => { + expect(messagePartBrainRef({ type: "tool", tool: "read" }, { filePath: "/tmp/example.ts" })).toEqual({ + label: "example.ts", + type: "resource", + consider: false, + path: "/tmp/example.ts", + }) + }) +}) diff --git a/packages/session-ui/src/components/message-part-brain-ref.ts b/packages/session-ui/src/components/message-part-brain-ref.ts new file mode 100644 index 0000000000..92284a4756 --- /dev/null +++ b/packages/session-ui/src/components/message-part-brain-ref.ts @@ -0,0 +1,7 @@ +import { amicoBrainRef, type AmicoBrainRef } from "@opencode-ai/ui/amicode-brain-ref" +import { hasStringToolName } from "./message-part-receipt-guard" + +export function messagePartBrainRef(value: unknown, input: Record = {}): AmicoBrainRef | undefined { + if (!hasStringToolName(value)) return + return amicoBrainRef(value.tool, input) +} diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index de23268e82..8871fc638f 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -2,7 +2,7 @@ import { AmicoSpinner } from "@opencode-ai/ui/amico-spinner" import { ThinkingLine, turnTokens } from "@opencode-ai/ui/amicode-thinking" import { shellRowLabel } from "@opencode-ai/ui/amicode-shell-row" import { sessionHasAmicodeParts } from "@opencode-ai/ui/amicode-rail-gate" -import { amicoBrainRef, emitAmicoBrainHover } from "@opencode-ai/ui/amicode-brain-ref" +import { emitAmicoBrainHover } from "@opencode-ai/ui/amicode-brain-ref" import { copyTextToClipboard } from "../util/clipboard" import { Component, @@ -700,6 +700,7 @@ import { type ReceiptCandidate, } from "@opencode-ai/ui/amicode-receipt-runs" import { amicodeReceiptCandidateKey } from "./message-part-receipts" +import { messagePartBrainRef } from "./message-part-brain-ref" function index(items: readonly T[]) { return new Map(items.map((item) => [item.id, item] as const)) @@ -1348,7 +1349,7 @@ export function ContextToolGroup(props: { } // amicode: hovering the group chip glances at every member node on the map const glanceAll = () => { - for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(amicoBrainRef(p.tool, p.state.input ?? {})) + for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(messagePartBrainRef(p, p.state.input ?? {})) } return ( @@ -1463,7 +1464,7 @@ export function ShellToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSiz } // amicode: hovering the group chip glances at every member node on the map const glanceAll = () => { - for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(amicoBrainRef(p.tool, p.state.input ?? {})) + for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(messagePartBrainRef(p, p.state.input ?? {})) } return ( @@ -1572,7 +1573,7 @@ export function EditToolGroup(props: { parts: ToolPart[]; busy?: boolean; onSize } // amicode: hovering the group chip glances at every member node on the map const glanceAll = () => { - for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(amicoBrainRef(p.tool, p.state.input ?? {})) + for (const p of props.parts.slice(0, 8)) emitAmicoBrainHover(messagePartBrainRef(p, p.state.input ?? {})) } return ( @@ -2093,7 +2094,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { const handleToolOpenChange = (open: boolean) => props.onToolOpenChange?.(open) // amicode: hovering the row glances at its node on the brain's map - const brainRef = createMemo(() => amicoBrainRef(part().tool, input())) + const brainRef = createMemo(() => messagePartBrainRef(part(), input())) return (