Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/app/src/context/file/path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/context/file/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
29 changes: 14 additions & 15 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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])

Expand Down Expand Up @@ -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<string>()
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 = () => {
Expand Down
73 changes: 73 additions & 0 deletions packages/app/src/pages/session/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
createSessionTabs,
focusTerminalById,
getTabReorderIndex,
normalizeSessionTab,
normalizeSessionTabs,
shouldShowFileTree,
} from "./helpers"
import { closeSessionTab, openSessionTab } from "@/context/layout-tabs"
Expand All @@ -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[] = []
Expand Down Expand Up @@ -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({
Expand Down
33 changes: 27 additions & 6 deletions packages/app/src/pages/session/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ type Tabs = {
type TabsInput = {
tabs: Accessor<Tabs>
pathFromTab: (tab: string) => string | undefined
normalizeTab: (tab: string) => string
normalizeTab: (tab: string) => string | undefined
review?: Accessor<boolean>
hasReview?: Accessor<boolean>
/** the Vault tab (amicode) — a named surface like "context", never a file */
Expand All @@ -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<string>()
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
}
Expand All @@ -49,14 +66,14 @@ export const createSessionTabs = (input: TabsInput) => {
const panelTabs = createMemo(
() => {
const seen = new Set<string>()
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]
})
Expand All @@ -68,15 +85,19 @@ 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
if (active === SESSION_PREVIEW_TAB && previewOpen()) return active
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
Expand Down
18 changes: 18 additions & 0 deletions packages/session-ui/src/components/message-part-brain-ref.test.ts
Original file line number Diff line number Diff line change
@@ -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",
})
})
})
7 changes: 7 additions & 0 deletions packages/session-ui/src/components/message-part-brain-ref.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}): AmicoBrainRef | undefined {
if (!hasStringToolName(value)) return
return amicoBrainRef(value.tool, input)
}
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 12 additions & 0 deletions packages/session-ui/src/components/message-part-receipts.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
22 changes: 7 additions & 15 deletions packages/session-ui/src/components/message-part.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T extends { id: string }>(items: readonly T[]) {
return new Map(items.map((item) => [item.id, item] as const))
Expand All @@ -712,13 +711,6 @@ function index<T extends { id: string }>(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<string, number>, b: Map<string, number>) {
if (a === b) return true
if (a.size !== b.size) return false
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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 (
<Show when={!hideQuestion()}>
<div
Expand Down
Loading