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.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 64f77e59c5..08d1a0d28b 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[] = [] @@ -127,6 +141,65 @@ 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("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("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 c3104919aa..5cbd48ba32 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 */ @@ -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 } @@ -49,14 +66,14 @@ 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 [] 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] }) @@ -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 @@ -76,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 (active && 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 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-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 new file mode 100644 index 0000000000..654eb7ca7f --- /dev/null +++ b/packages/session-ui/src/components/message-part-receipts.ts @@ -0,0 +1,12 @@ +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" || !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.tsx b/packages/session-ui/src/components/message-part.tsx index 4d932c8014..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, @@ -694,14 +694,13 @@ 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" +import { messagePartBrainRef } from "./message-part-brain-ref" function index(items: readonly T[]) { return new Map(items.map((item) => [item.id, item] as const)) @@ -712,13 +711,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. -function amicodeReceiptCandidateKey(part: PartType | undefined): { key?: ReceiptKey; seq?: number } { - if (!part || part.type !== "tool" || !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 @@ -1357,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 ( @@ -1472,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 ( @@ -1581,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 ( @@ -2102,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 (