feat(desktop): replace the settings trace list with an in-session trajectory view (#1443)

Sessions get a Chat / Trajectory switch in the header. The trajectory is a
dense one-line-per-event ledger (system prompt, user, injected context such
as skills and system reminders, assistant responses, tool calls) with a
three-lane minimap, turn folding, search, and a detail panel per row.

- CLI: record a deduplicated prompt snapshot sidecar for desktop sessions
  (system prompt, tool catalog, user context) without touching the transcript.
- Server: project transcript records into trajectory rows with bounded,
  cursor-paged reads, live appends, a turn index, row detail by byte range,
  snapshot blobs, and a time-window lookup into the raw trace capture.
- Desktop: windowed ledger, minimap, detail panel with a raw request summary,
  chat <-> trajectory navigation, subagent drill-in.
- Remove the Settings trace list, trace tabs and the standalone trace
  window; migrate persisted trace tabs to session tabs.
This commit is contained in:
程序员阿江-Relakkes
2026-10-04 17:14:07 +08:00
committed by GitHub
parent 5ae39d670c
commit 3754d1f2a5
129 changed files with 11099 additions and 6348 deletions
+2 -2
View File
@@ -89,8 +89,8 @@ describe('Electron IPC capabilities', () => {
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardReadText, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, 'paste me')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.clipboardWriteText, { text: 'paste me' })).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.traceOpenWindow, '../escape')).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '4673a448-9e2c-475e-898d-9aa0ee2d1ab7')).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.petsFocusSession, '../escape')).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, undefined)).toBe(true)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowClose, {})).toBe(false)
expect(validateElectronIpcPayload(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)).toBe(true)
-1
View File
@@ -345,7 +345,6 @@ export const ELECTRON_IPC_VALIDATORS = {
[ELECTRON_IPC_CHANNELS.clipboardWriteText]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpen]: stringPayload,
[ELECTRON_IPC_CHANNELS.shellOpenPath]: stringPayload,
[ELECTRON_IPC_CHANNELS.traceOpenWindow]: sessionIdPayload,
[ELECTRON_IPC_CHANNELS.petsList]: noPayload,
[ELECTRON_IPC_CHANNELS.petsCreateFromImage]: petCreateFromAtlas,
[ELECTRON_IPC_CHANNELS.petsCreateFromAtlas]: petCreateFromAtlas,
-1
View File
@@ -17,7 +17,6 @@ export const ELECTRON_IPC_CHANNELS = {
clipboardWriteText: 'desktop:clipboard:write-text',
shellOpen: 'desktop:shell:open',
shellOpenPath: 'desktop:shell:open-path',
traceOpenWindow: 'desktop:trace:open-window',
petsList: 'desktop:pets:list',
petsCreateFromImage: 'desktop:pets:create-from-image',
petsCreateFromAtlas: 'desktop:pets:create-from-atlas',
+2 -42
View File
@@ -119,7 +119,6 @@ let previewService: ElectronPreviewService | null = null
let workspaceBrowserService: ElectronWorkspaceBrowserService | null = null
let workspaceBrowserSessionConfigured = false
let petWindowController: PetWindowController | null = null
const traceWindows = new Map<string, BrowserWindow>()
let isQuitting = false
let quitCleanupStarted = false
let quitCleanupFinished = false
@@ -197,10 +196,7 @@ function installSystemAppearanceWatch() {
const current = currentAppearance()
if (current && !current.followSystem) return
const background = startupWindowBackground(current, nativeTheme.shouldUseDarkColors)
for (const window of [mainWindow, ...traceWindows.values()]) {
if (!window || window.isDestroyed()) continue
window.setBackgroundColor(background)
}
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.setBackgroundColor(background)
})
}
@@ -220,41 +216,6 @@ async function loadRendererEntry(
}
}
async function openTraceWindow(sessionId: string) {
const existing = traceWindows.get(sessionId)
if (existing && !existing.isDestroyed()) {
showMainWindow(existing, app)
return
}
const traceWindow = new BrowserWindow({
width: 1180,
height: 780,
minWidth: 860,
minHeight: 560,
title: 'Trace',
autoHideMenuBar: true,
show: false,
backgroundColor: resolveStartupWindowBackground(),
webPreferences: {
preload: preloadPath(),
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
},
})
traceWindows.set(sessionId, traceWindow)
traceWindow.on('closed', () => {
traceWindows.delete(sessionId)
})
installMainWindowNavigationGuards(traceWindow.webContents, { openExternal: openExternalUrl })
await loadRendererEntry(traceWindow, {
traceWindow: '1',
traceSessionId: sessionId,
})
showMainWindow(traceWindow, app)
}
function getServerRuntime() {
serverRuntime ??= new ElectronServerRuntime({
onServerUnavailable: () => { void publicAccessManager?.serverUnavailable() },
@@ -625,7 +586,6 @@ function registerIpcHandlers() {
registerHandler(ELECTRON_IPC_CHANNELS.clipboardWriteText, (_event, payload) => clipboard.writeText(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpen, (_event, payload) => openExternalUrl(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.shellOpenPath, (_event, payload) => openSystemPath(resolveRelocatedAttachmentPath(String(payload))))
registerHandler(ELECTRON_IPC_CHANNELS.traceOpenWindow, (_event, payload) => openTraceWindow(String(payload)))
registerHandler(ELECTRON_IPC_CHANNELS.petsList, () => listCustomPets())
registerHandler(ELECTRON_IPC_CHANNELS.petsCreateFromImage, async (event, payload) => {
const input = payload as {
@@ -926,7 +886,7 @@ function registerIpcHandlers() {
applyAppliedAppearance(payload, {
app,
// The pet window is deliberately transparent, so it stays out of this.
windows: () => [mainWindow, ...traceWindows.values()].filter((window): window is BrowserWindow => !!window),
windows: () => mainWindow ? [mainWindow] : [],
})
})
}
@@ -32,9 +32,9 @@ function setup(options: {
entry?: string
} = {}) {
const primary = { id: 1 } as unknown as WebContents
const trace = { id: 3 } as unknown as WebContents
const secondary = { id: 3 } as unknown as WebContents
const other = { id: 2 } as unknown as WebContents
const appContents = new Set<WebContents>([primary, trace])
const appContents = new Set<WebContents>([primary, secondary])
let check!: CheckHandler
let request!: RequestHandler
const session = {
@@ -75,7 +75,7 @@ function setup(options: {
})
return {
primary,
trace,
secondary,
other,
systemPreferences,
ask,
@@ -90,9 +90,9 @@ describe('installMicrophonePermissions request handler', () => {
await expect(ask(primary, {})).resolves.toBe(true)
})
it('grants audio-only capture to the detached trace window, whatever its query string', async () => {
const { trace, ask } = setup({ platform: 'linux' })
await expect(ask(trace, { requestingUrl: `${APP_URL}?traceWindow=1&traceSessionId=abc` })).resolves.toBe(true)
it('grants audio-only capture to a secondary application window, whatever its query string', async () => {
const { secondary, ask } = setup({ platform: 'linux' })
await expect(ask(secondary, { requestingUrl: `${APP_URL}?petWindow=1&sessionId=abc` })).resolves.toBe(true)
})
it('denies audio requests from contents that are not an application window', async () => {
@@ -102,18 +102,18 @@ describe('installMicrophonePermissions request handler', () => {
})
it('denies once the application window is gone', async () => {
const { primary, trace, ask, closeWindow } = setup({ platform: 'win32' })
const { primary, secondary, ask, closeWindow } = setup({ platform: 'win32' })
closeWindow(primary)
closeWindow(trace)
closeWindow(secondary)
await expect(ask(primary, {})).resolves.toBe(false)
await expect(ask(trace, {})).resolves.toBe(false)
await expect(ask(secondary, {})).resolves.toBe(false)
})
it('still denies subframes and video from the trace window', async () => {
const { trace, ask } = setup({ platform: 'win32' })
await expect(ask(trace, { isMainFrame: false })).resolves.toBe(false)
await expect(ask(trace, { mediaTypes: ['audio', 'video'] })).resolves.toBe(false)
await expect(ask(trace, { requestingUrl: 'https://example.com/' })).resolves.toBe(false)
it('still denies subframes and video from a secondary application window', async () => {
const { secondary, ask } = setup({ platform: 'win32' })
await expect(ask(secondary, { isMainFrame: false })).resolves.toBe(false)
await expect(ask(secondary, { mediaTypes: ['audio', 'video'] })).resolves.toBe(false)
await expect(ask(secondary, { requestingUrl: 'https://example.com/' })).resolves.toBe(false)
})
it('denies subframes and foreign or missing frame URLs', async () => {
@@ -192,9 +192,9 @@ describe('installMicrophonePermissions check handler', () => {
}
})
it('reports audio as permitted for the trace window but not for non-application contents', () => {
const { trace, other, probe } = setup({ platform: 'win32' })
expect(probe(trace, { requestingUrl: `${APP_URL}?traceWindow=1` })).toBe(true)
it('reports audio as permitted for a secondary application window but not for non-application contents', () => {
const { secondary, other, probe } = setup({ platform: 'win32' })
expect(probe(secondary, { requestingUrl: `${APP_URL}?petWindow=1` })).toBe(true)
expect(probe(other)).toBe(false)
})
@@ -222,7 +222,7 @@ describe('createRendererUrlMatcher', () => {
it('matches the packaged index.html regardless of query and hash', () => {
const matches = createRendererUrlMatcher(APP_ENTRY)
expect(matches(APP_URL)).toBe(true)
expect(matches(`${APP_URL}?traceWindow=1#/chat`)).toBe(true)
expect(matches(`${APP_URL}?petWindow=1#/chat`)).toBe(true)
expect(matches(pathToFileURL('/Applications/Other.app/dist/index.html').toString())).toBe(false)
expect(matches('https://example.com/dist/index.html')).toBe(false)
expect(matches('not a url')).toBe(false)
@@ -259,7 +259,7 @@ describe('main window wiring', () => {
it('installs the microphone policy once, on the shared app session, for any application window', () => {
expect(mainWindowSource).toContain('installMicrophonePermissions(mainWindow.webContents.session')
expect(mainSource.match(/installMicrophonePermissions\(/g)).toHaveLength(1)
// A detached trace window renders the same ChatInput, so the gate cannot be "is mainWindow".
// The policy covers every application window, so the gate cannot be "is mainWindow".
expect(mainWindowSource).toContain('BrowserWindow.fromWebContents(contents)')
expect(mainWindowSource).not.toMatch(/primary:/)
})
@@ -13,8 +13,8 @@ export type MicrophoneSystemPreferences = {
export type MicrophonePermissionOptions = {
/**
* True for contents that live in a window this application created and that
* is still open (main window and the detached trace window, both of which
* render the chat input).
* is still open. Deliberately not "is the main window": the policy belongs
* to every application window, not to one specific window.
*/
isAppContents: (contents: WebContents) => boolean
/** True only for a frame URL that is the application's own renderer entry. */
@@ -25,8 +25,8 @@ export type MicrophonePermissionOptions = {
/**
* Match the application's own renderer document: the dev server origin, or the
* packaged `dist/index.html`. Query and hash are ignored because the main and
* trace windows load the same entry with different query strings.
* packaged `dist/index.html`. Query and hash are ignored because every
* application window loads the same entry with its own query string.
*/
export function createRendererUrlMatcher(
entry: string,
+16 -13
View File
@@ -127,9 +127,6 @@ vi.mock('../pages/ActivitySettings', () => ({
ActivitySettings: () => <div>Activity Settings Mock</div>,
}))
vi.mock('../pages/TraceList', () => ({
TraceList: () => <div>Trace List Mock</div>,
}))
vi.mock('../stores/agentStore', () => ({
useAgentStore: () => ({
@@ -1203,18 +1200,10 @@ describe('Settings > General tab', () => {
expect(screen.getByText('Activity Settings Mock')).toBeInTheDocument()
})
it('opens the Trace tab from Settings navigation between Token usage and Diagnostics', () => {
it('does not list a Trace section in Settings navigation', () => {
render(<Settings />)
const usageTab = screen.getByText('Token usage')
const traceTab = screen.getByText('Trace')
const diagnosticsTab = screen.getByText('Diagnostics')
expect((usageTab.compareDocumentPosition(traceTab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
expect((traceTab.compareDocumentPosition(diagnosticsTab) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
fireEvent.click(traceTab)
expect(screen.getByText('Trace List Mock')).toBeInTheDocument()
expect(screen.queryByText('Trace')).not.toBeInTheDocument()
})
it('lets the user disable WebFetch preflight skipping', () => {
@@ -1462,6 +1451,20 @@ describe('Settings > General tab', () => {
expect(screen.getByText('Message Sending')).toBeInTheDocument()
})
it('points the capture hint at the session Trajectory view instead of the removed trace list', async () => {
render(<Settings />)
fireEvent.click(screen.getByText('General'))
expect(screen.getByText(/switch to Raw request/)).toBeInTheDocument()
await act(async () => {
fireEvent.click(screen.getByLabelText('Collect agent traces'))
})
expect(screen.getByText(/Raw request in each session's Trajectory view/)).toBeInTheDocument()
expect(screen.queryByText(/trace list/i)).not.toBeInTheDocument()
})
it('uses the shared dropdown for response language', () => {
render(<Settings />)
+4 -2
View File
@@ -530,8 +530,10 @@ export const sessionsApi = {
return api.get<TraceSession>(`/api/sessions/${sessionId}/trace${suffix}`, options)
},
getTraceCall(sessionId: string, callId: string) {
return api.get<{ call: TraceCallRecord }>(`/api/sessions/${sessionId}/trace/calls/${callId}`)
/** `at` is the record locator from `/trace/near`, needed for calls outside the indexed window. */
getTraceCall(sessionId: string, callId: string, at?: string) {
const suffix = at ? `?${new URLSearchParams({ at })}` : ''
return api.get<{ call: TraceCallRecord }>(`/api/sessions/${sessionId}/trace/calls/${callId}${suffix}`)
},
create(input?: string | CreateSessionRequest) {
+2 -34
View File
@@ -1,35 +1,7 @@
import { api, type ApiRequestOptions } from './client'
import type { TraceCaptureSettings, TraceSessionDeleteResult, TraceSessionList } from '../types/trace'
export type TraceSessionRevision = {
sessionId: string
revision: number
revisionToken?: string
changed: boolean
reset: boolean
}
import { api } from './client'
import type { TraceCaptureSettings } from '../types/trace'
export const tracesApi = {
list(options?: { limit?: number; offset?: number; query?: string }, requestOptions?: ApiRequestOptions) {
const params = new URLSearchParams()
if (options?.limit !== undefined) params.set('limit', String(options.limit))
if (options?.offset !== undefined) params.set('offset', String(options.offset))
if (options?.query) params.set('q', options.query)
const suffix = params.toString() ? `?${params}` : ''
return api.get<TraceSessionList>(`/api/traces${suffix}`, requestOptions)
},
getRevision(sessionId: string, sinceRevision?: number, sinceRevisionToken?: string, options?: ApiRequestOptions) {
const params = new URLSearchParams()
if (sinceRevision !== undefined) params.set('sinceRevision', String(sinceRevision))
if (sinceRevisionToken !== undefined) params.set('sinceRevisionToken', sinceRevisionToken)
const suffix = params.toString() ? `?${params}` : ''
return api.get<TraceSessionRevision>(
`/api/traces/${encodeURIComponent(sessionId)}/revision${suffix}`,
options,
)
},
getSettings() {
return api.get<TraceCaptureSettings>('/api/traces/settings')
},
@@ -37,8 +9,4 @@ export const tracesApi = {
updateSettings(settings: Partial<Pick<TraceCaptureSettings, 'enabled'>>) {
return api.put<TraceCaptureSettings>('/api/traces/settings', settings)
},
deleteSession(sessionId: string) {
return api.delete<TraceSessionDeleteResult>(`/api/traces/${encodeURIComponent(sessionId)}`)
},
}
+51
View File
@@ -0,0 +1,51 @@
import { api, type ApiRequestOptions } from './client'
import type { TraceCallsNear, TrajectoryLoc, TrajectoryPage, TrajectoryRowDetail, TrajectorySnapshotBlob } from '../types/trajectory'
function sessionPath(sessionId: string): string {
return `/api/sessions/${encodeURIComponent(sessionId)}/trajectory`
}
export const trajectoryApi = {
/** Tail page when neither `cursor` nor `after` is given. */
getPage(
sessionId: string,
page: { cursor?: string; after?: string; agentId?: string } = {},
options?: ApiRequestOptions,
) {
const query = new URLSearchParams()
if (page.cursor) query.set('cursor', page.cursor)
if (page.after) query.set('after', page.after)
if (page.agentId) query.set('agentId', page.agentId)
const suffix = query.size ? `?${query}` : ''
return api.get<TrajectoryPage>(`${sessionPath(sessionId)}${suffix}`, options)
},
getRow(
sessionId: string,
rowId: string,
loc: TrajectoryLoc[],
agentId?: string,
options?: ApiRequestOptions,
) {
const query = new URLSearchParams()
query.set('loc', loc.map(([start, end]) => `${start}-${end}`).join(','))
if (agentId) query.set('agentId', agentId)
return api.get<TrajectoryRowDetail>(
`${sessionPath(sessionId)}/rows/${encodeURIComponent(rowId)}?${query}`,
options,
)
},
/** Captured model calls that started inside `[from, to]` (located by time through the capture index). */
getTraceCallsNear(sessionId: string, from: string, to: string, options?: ApiRequestOptions) {
const query = new URLSearchParams({ from, to })
return api.get<TraceCallsNear>(`/api/sessions/${encodeURIComponent(sessionId)}/trace/near?${query}`, options)
},
getSnapshot(sessionId: string, hash: string, options?: ApiRequestOptions) {
return api.get<TrajectorySnapshotBlob>(
`${sessionPath(sessionId)}/snapshots/${encodeURIComponent(hash)}`,
options,
)
},
}
+23 -2
View File
@@ -1,4 +1,4 @@
import { memo, useMemo, useState } from 'react'
import { memo, useEffect, useMemo, useState } from 'react'
import { getDisclosure, setDisclosure } from '../../lib/disclosureMemory'
import { toolResultImagesFor, type ToolResultImageExtraction } from '@/lib/toolResultContent'
import { CircleX } from 'lucide-react'
@@ -35,6 +35,12 @@ type Props = {
isLive?: boolean
/** Stable key that survives virtualized row unmount/remount. */
disclosureKey?: string
/**
* A "locate in chat" jump landed on this call. A folded run would hide it,
* so the run opens (and stays open, as if the reader had clicked) and the
* row is marked while the jump's highlight lasts.
*/
revealToolUseId?: string
}
/**
@@ -62,6 +68,7 @@ export const ActivityGroup = memo(function ActivityGroup({
isStreaming,
isLive = false,
disclosureKey,
revealToolUseId,
}: Props) {
const t = useTranslation()
/** null = follow the run's own state; set = the reader decided. */
@@ -75,6 +82,12 @@ export const ActivityGroup = memo(function ActivityGroup({
}
const toolCalls = useMemo(() => activityStepToolCalls(steps), [steps])
const revealsHere = revealToolUseId !== undefined && toolCalls.some((toolCall) => toolCall.toolUseId === revealToolUseId)
useEffect(() => {
if (revealsHere) setPinnedCollapsed(false)
// Pin once per request; `setPinnedCollapsed` is recreated every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [revealsHere, revealToolUseId])
const failedCount = countFailedToolCalls(toolCalls, resultMap, childToolCallsByParent)
const hasActiveThinking = Boolean(activeThinkingId) && steps.some(
(step) => step.kind === 'thinking' && step.message.id === activeThinkingId,
@@ -87,7 +100,9 @@ export const ActivityGroup = memo(function ActivityGroup({
// now". The latter flickers: a run of six tools resolves and restarts six
// times, and folding on each gap made the whole block open and shut under the
// reader while they were trying to watch it.
const collapsed = pinnedCollapsed ?? !isLive
// Open in the same render the request arrives in, so the row is in the DOM
// for the scroll that follows; the effect above makes it stick.
const collapsed = revealsHere ? false : (pinnedCollapsed ?? !isLive)
const soleToolCall = steps.length === 1 && steps[0]?.kind === 'tool' ? steps[0].toolCall : null
if (soleToolCall) {
@@ -101,6 +116,7 @@ export const ActivityGroup = memo(function ActivityGroup({
toolCall={soleToolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
revealToolUseId={revealToolUseId}
/>
</div>
</div>
@@ -170,6 +186,7 @@ export const ActivityGroup = memo(function ActivityGroup({
toolCall={step.toolCall}
resultMap={resultMap}
childToolCallsByParent={childToolCallsByParent}
revealToolUseId={revealToolUseId}
/>
))}
</div>
@@ -243,10 +260,12 @@ function ActivityToolRow({
toolCall,
resultMap,
childToolCallsByParent,
revealToolUseId,
}: {
toolCall: ToolCall
resultMap: Map<string, ToolResult>
childToolCallsByParent: Map<string, ToolCall[]>
revealToolUseId?: string
}) {
const result = resultMap.get(toolCall.toolUseId)
const childToolCalls = childToolCallsByParent.get(toolCall.toolUseId) ?? []
@@ -262,6 +281,8 @@ function ActivityToolRow({
status={toolCall.status}
partialInput={toolCall.partialInput}
durationMs={toolCallDurationMs(toolCall, result)}
toolUseId={toolCall.parentToolUseId ? undefined : toolCall.toolUseId}
navigationHighlighted={revealToolUseId !== undefined && toolCall.toolUseId === revealToolUseId}
/>
{childToolCalls.length > 0 && (
<div className="ml-2 border-l border-[var(--color-border)] pl-3">
@@ -1,8 +1,30 @@
import { render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { beforeEach, describe, expect, it } from 'vitest'
import { CodeViewer } from './CodeViewer'
import { useSettingsStore } from '../../stores/settingsStore'
describe('CodeViewer', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
})
it('translates its line count and copy action', () => {
// Regression: the header spelled "3 lines" and the copy button "Copy" in
// English whatever the interface language was.
useSettingsStore.setState({ locale: 'zh' })
render(<CodeViewer code={'{\n "a": 1\n}'} language="json" />)
expect(screen.getByText('3 行')).toBeTruthy()
expect(screen.queryByText(/lines?$/)).toBeNull()
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('counts a single line in the singular', () => {
render(<CodeViewer code="ls" language="bash" />)
expect(screen.getByText('1 line')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Copy' })).toBeTruthy()
})
it('keeps the same inner padding for highlighted code content', () => {
const { container } = render(
<CodeViewer code={'cd testb\nnpm run dev'} language="bash" showLineNumbers />,
@@ -37,4 +59,17 @@ describe('CodeViewer', () => {
expect(contentWrapper?.style.whiteSpace).toBe('pre-wrap')
expect(contentWrapper?.style.wordBreak).toBe('break-word')
})
it("caps the code area by default, so a tool card cannot take over the chat", () => {
const { container } = render(<CodeViewer code={"a\nb"} language="json" />)
expect(container.querySelector(".code-viewer-area")?.className).toContain("max-h-[420px]")
})
it("lets a host that already scrolls grow the code area with its content", () => {
// Regression: inside the trajectory detail panel the 420px box scrolled on
// its own and left the rest of the panel empty below it.
const { container } = render(<CodeViewer code={"a\nb"} language="json" unboundedHeight />)
const area = container.querySelector(".code-viewer-area")
expect(area?.className).not.toContain("max-h-")
expect(area?.getAttribute("data-unbounded-height")).toBe("true")
})
})
+18 -2
View File
@@ -13,6 +13,12 @@ type Props = {
wrapLongLines?: boolean
chrome?: CodeViewerChrome
label?: string
/**
* Drop the 420px cap on the code area and let it grow with its content.
* For hosts that already scroll (a side panel): a capped box inside a
* scrolling panel leaves the panel's own space empty below it.
*/
unboundedHeight?: boolean
}
const warmPrismTheme: PrismTheme = {
@@ -189,11 +195,13 @@ function CodeArea({
language,
showLineNumbers,
wrapLongLines,
unboundedHeight,
}: {
code: string
language?: string
showLineNumbers: boolean
wrapLongLines: boolean
unboundedHeight: boolean
}) {
const containerRef = useRef<HTMLDivElement>(null)
const [runtime, setRuntime] = useState<ShikiRuntime | null>(null)
@@ -236,7 +244,8 @@ function CodeArea({
<div
ref={containerRef}
data-has-line-numbers={showLineNumbers ? 'true' : 'false'}
className="code-viewer-area relative max-h-[420px] overflow-auto bg-[var(--color-code-bg)]"
data-unbounded-height={unboundedHeight ? 'true' : undefined}
className={`code-viewer-area relative overflow-auto bg-[var(--color-code-bg)] ${unboundedHeight ? '' : 'max-h-[420px]'}`}
>
{(!ShikiHighlighter || !loaded) && (
<PrismCodeContent
@@ -294,6 +303,7 @@ export function CodeViewer({
wrapLongLines = false,
chrome = 'card',
label,
unboundedHeight = false,
}: Props) {
const t = useTranslation()
const [expanded, setExpanded] = useState(false)
@@ -305,7 +315,10 @@ export function CodeViewer({
const effectiveShowLineNumbers = showLineNumbers && !!language && language !== 'text'
const languageLabel = language || 'code'
const lineCountLabel = `${allLines.length} ${allLines.length === 1 ? 'line' : 'lines'}`
const lineCountLabel = t(
allLines.length === 1 ? 'tool.lineCountSingular' : 'tool.lineCountPlural',
{ count: allLines.length },
)
const showExpandToggle = allLines.length > maxLines
return (
@@ -334,6 +347,8 @@ export function CodeViewer({
</div>
<CopyButton
text={code}
label={t('common.copy')}
copiedLabel={t('common.copied')}
className={
isEmbedded
? 'rounded-[var(--radius-sm)] px-2 py-1 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-surface-container-high)] hover:text-[var(--color-text-primary)]'
@@ -348,6 +363,7 @@ export function CodeViewer({
language={language}
showLineNumbers={effectiveShowLineNumbers}
wrapLongLines={wrapLongLines}
unboundedHeight={unboundedHeight}
/>
{/* Expand/collapse toggle */}
@@ -701,7 +701,7 @@ export function MermaidRenderer({ code }: Props) {
>
{t('mermaid.preview')}
</Button>
<CopyButton text={code} className={COPY_CHIP_CLASS} />
<CopyButton text={code} label={t('common.copy')} copiedLabel={t('common.copied')} className={COPY_CHIP_CLASS} />
</div>
</div>
@@ -767,7 +767,7 @@ export function MermaidRenderer({ code }: Props) {
onClick={zoomIn}
/>
</div>
<CopyButton text={code} className={COPY_CHIP_CLASS} />
<CopyButton text={code} label={t('common.copy')} copiedLabel={t('common.copied')} className={COPY_CHIP_CLASS} />
</div>
</div>
<div
@@ -35,6 +35,7 @@ import { CHAT_APPEARANCE_STORAGE_KEY } from '../../lib/chatAppearance'
import { useSessionStore } from '../../stores/sessionStore'
import { useSideChatStore } from '../../stores/sideChatStore'
import { useTabStore } from '../../stores/tabStore'
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
import { useUIStore } from '../../stores/uiStore'
import { useTeamStore } from '../../stores/teamStore'
import { formatExactMessageTimestamp, formatMessageHoverTime } from '../../lib/formatMessageTimestamp'
@@ -1222,6 +1223,103 @@ describe('MessageList nested tool calls', () => {
expect(container.querySelector('[data-chat-render-item-key="user-0"]')?.className).toContain('chat-render-item--navigation-target')
})
it('scrolls to and highlights the message a trajectory row asks to locate', async () => {
const messages: UIMessage[] = Array.from({ length: 220 }, (_, index) => ({
id: `${index % 2 === 0 ? 'user' : 'assistant'}-${index}`,
type: index % 2 === 0 ? 'user_text' : 'assistant_text',
content: `${index % 2 === 0 ? 'Prompt' : 'Answer'} ${index}`,
timestamp: index,
})) as UIMessage[]
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages }) } })
useTrajectoryViewStore.setState({ nav: null, modes: {}, opened: {} })
const { container } = render(<MessageList />)
const scroller = container.querySelector('.chat-scroll-area') as HTMLElement
let scrollTop = 24_000
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 500 })
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 25_000 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value },
})
// An unknown uuid first, then the real one: any listed uuid identifies the item.
act(() => useTrajectoryViewStore.getState().revealInChat(ACTIVE_TAB, { uuids: ['not-loaded', 'assistant-1'] }))
await waitFor(() => expect(container.querySelector('[data-chat-render-item-key="assistant-1"]')?.className).toContain('chat-render-item--navigation-target'))
expect(scrollTop).toBeLessThan(1000)
expect(container.querySelector('[data-chat-render-item-key="user-0"]')?.className ?? '').not.toContain('chat-render-item--navigation-target')
expect(useTrajectoryViewStore.getState().nav).toBeNull()
})
it('opens a folded run onto the tool call a trajectory row locates, and marks that row', async () => {
// Regression: the jump only scrolled to the run and tinted it, but a settled
// run is folded to its summary line, so the call it was about stayed hidden.
const messages: UIMessage[] = [
{ id: 'locate-user', type: 'user_text', content: 'Inspect the repo', timestamp: 1 },
{ id: 'locate-read', type: 'tool_use', toolName: 'Read', toolUseId: 'toolu-locate-read', input: { file_path: '/repo/a.ts' }, timestamp: 2 },
{ id: 'locate-read-result', type: 'tool_result', toolUseId: 'toolu-locate-read', content: 'a', isError: false, timestamp: 3 },
{ id: 'locate-bash', type: 'tool_use', toolName: 'Bash', toolUseId: 'toolu-locate-bash', input: { command: 'ls' }, timestamp: 4 },
{ id: 'locate-bash-result', type: 'tool_result', toolUseId: 'toolu-locate-bash', content: 'a.ts', isError: false, timestamp: 5 },
{ id: 'locate-grep', type: 'tool_use', toolName: 'Grep', toolUseId: 'toolu-locate-grep', input: { pattern: 'x' }, timestamp: 6 },
{ id: 'locate-grep-result', type: 'tool_result', toolUseId: 'toolu-locate-grep', content: '', isError: false, timestamp: 7 },
{ id: 'locate-answer', type: 'assistant_text', content: 'Done.', timestamp: 8 },
] as UIMessage[]
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages }) } })
useTrajectoryViewStore.setState({ nav: null, modes: {}, opened: {} })
const { container } = render(<MessageList />)
const scroller = container.querySelector('.chat-scroll-area') as HTMLElement
const scrollWrites: number[] = []
let scrollTop = 0
Object.defineProperty(scroller, 'clientHeight', { configurable: true, value: 500 })
Object.defineProperty(scroller, 'scrollHeight', { configurable: true, value: 4_000 })
Object.defineProperty(scroller, 'scrollTop', {
configurable: true,
get: () => scrollTop,
set: (value: number) => { scrollTop = value; scrollWrites.push(value) },
})
// Only the located row has a box; the run around it measures as empty, so
// any correction can only have come from aligning the row itself.
const realRect = HTMLElement.prototype.getBoundingClientRect
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
if (this.dataset.toolUseId === 'toolu-locate-bash') return { top: 900, bottom: 920, height: 20, left: 0, right: 100, width: 100, x: 0, y: 900, toJSON: () => ({}) } as DOMRect
return realRect.call(this)
})
const group = screen.getByTestId('activity-group')
expect(group.getAttribute('data-expanded')).toBe('false')
expect(container.querySelector('[data-tool-use-id="toolu-locate-bash"]')).toBeNull()
act(() => useTrajectoryViewStore.getState().revealInChat(ACTIVE_TAB, { toolUseId: 'toolu-locate-bash' }))
await waitFor(() => expect(container.querySelector('[data-tool-use-id="toolu-locate-bash"]')?.className).toContain('chat-tool-navigation-target'))
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
// Exactly the located call is marked, not its neighbours.
expect(container.querySelectorAll('.chat-tool-navigation-target')).toHaveLength(1)
expect(container.querySelector('[data-tool-use-id="toolu-locate-read"]')?.className).not.toContain('chat-tool-navigation-target')
// Aligned to the row: 900px down, brought to the 25% reading line of a 500px viewport.
await waitFor(() => expect(scrollWrites.length).toBeGreaterThanOrEqual(2))
expect(scrollWrites.at(-1)! - scrollWrites.at(-2)!).toBe(900 - 500 * 0.25)
// The mark is brief; the run stays open as if the reader had opened it.
await waitFor(
() => expect(container.querySelector('.chat-tool-navigation-target')).toBeNull(),
{ timeout: 3_000 },
)
expect(screen.getByTestId('activity-group').getAttribute('data-expanded')).toBe('true')
})
it('ignores a locate request addressed to another session', async () => {
useChatStore.setState({ sessions: { [ACTIVE_TAB]: makeSessionState({ messages: makeConversationNavigationMessages() }) } })
useTrajectoryViewStore.setState({ nav: null, modes: {}, opened: {} })
render(<MessageList />)
act(() => useTrajectoryViewStore.getState().revealInChat('other-session', { uuids: ['user-0'] }))
await new Promise((resolve) => setTimeout(resolve, 50))
expect(useTrajectoryViewStore.getState().nav).toMatchObject({ sessionId: 'other-session' })
})
it('keeps streaming output out of the user-turn navigator after a prompt jump', async () => {
const messages = makeConversationNavigationMessages()
useChatStore.setState({
+71 -4
View File
@@ -55,6 +55,8 @@ import { formatTokenCount } from '../../lib/formatTokenCount'
import { formatDurationMs, hasRunningBackgroundTasks as hasAnyRunningBackgroundTasks } from '../../lib/backgroundTasks'
import { buildTurnCompletionByMessageId, type TurnCompletion } from '../../lib/turnCompletion'
import { isTouchH5Document } from '../../lib/touchH5'
import { findChatRenderTarget } from '../../lib/trajectory/chatTarget'
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
import {
EMPTY_TEAM_LIFECYCLE_CURSOR,
isTeamLifecycleScopedAt,
@@ -2408,6 +2410,8 @@ export function MessageList({
})
const [measuredItemsVersion, setMeasuredItemsVersion] = useState(0)
const [highlightedNavigationItemKey, setHighlightedNavigationItemKey] = useState<string | null>(null)
/** The tool call inside the highlighted item a jump is about, if any. */
const [highlightedNavigationToolUseId, setHighlightedNavigationToolUseId] = useState<string | null>(null)
const [programmaticNavigationItemId, setProgrammaticNavigationItemId] = useState<string | null>(null)
const [activeConversationFindMatch, setActiveConversationFindMatch] = useState<ConversationFindMatch | null>(null)
const conversationFindMatchesRef = useRef<ConversationFindMatch[]>([])
@@ -3314,7 +3318,10 @@ export function MessageList({
return result
}, [toolResultMap])
const handleNavigateToConversationItem = useCallback((item: ConversationNavigationItem) => {
const handleNavigateToConversationItem = useCallback((
item: ConversationNavigationItem,
options: { toolUseId?: string } = {},
) => {
const container = scrollContainerRef.current
if (!container) return
@@ -3322,6 +3329,9 @@ export function MessageList({
userScrollIntentUntilRef.current = 0
setProgrammaticNavigationItemId(item.id)
setHighlightedNavigationItemKey(item.renderItemKey)
// A tool call inside the item: its group opens onto it and the call itself
// takes the highlight, since the item is mostly the rows around it.
setHighlightedNavigationToolUseId(options.toolUseId ?? null)
const scheduleHighlightClear = () => {
if (navigationHighlightTimerRef.current !== null) {
@@ -3354,10 +3364,22 @@ export function MessageList({
}
setVirtualViewport({ scrollTop: targetScrollTop, viewportHeight })
requestAnimationFrame(() => {
const targetNode = Array.from(
const alignTarget = (retriesLeft: number) => {
const itemNode = Array.from(
scrollContentRef.current?.querySelectorAll<HTMLElement>('[data-chat-render-item-key]') ?? [],
).find((node) => node.dataset.chatRenderItemKey === item.renderItemKey)
// Align the call itself, not the top of a group that may be thousands of
// rows tall. The group opens in the render this navigation triggered, so
// give it one more frame if the row is not in the DOM yet.
const toolNode = options.toolUseId && itemNode
? Array.from(itemNode.querySelectorAll<HTMLElement>('[data-tool-use-id]'))
.find((node) => node.dataset.toolUseId === options.toolUseId)
: undefined
if (options.toolUseId && itemNode && !toolNode && retriesLeft > 0) {
requestAnimationFrame(() => alignTarget(retriesLeft - 1))
return
}
const targetNode = toolNode ?? itemNode
if (targetNode) {
const targetRect = targetNode.getBoundingClientRect()
@@ -3372,7 +3394,8 @@ export function MessageList({
}
scheduleHighlightClear()
})
}
requestAnimationFrame(() => alignTarget(1))
}, [
syncVirtualViewportFromContainer,
virtualTranscriptWindow.offsets,
@@ -3408,6 +3431,33 @@ export function MessageList({
conversationFindStreamingTextRef.current = streamingText
const conversationFindControllerRef = useRef<ConversationFindController | null>(null)
// "Locate in chat" from the trajectory view. The request arrives together
// with the switch back to chat, so wait two frames for the list to have real
// dimensions again before measuring offsets.
const trajectoryNav = useTrajectoryViewStore((state) => (
state.nav?.to === 'chat' && state.nav.sessionId === resolvedSessionId ? state.nav : null
))
const consumeTrajectoryNav = useTrajectoryViewStore((state) => state.consumeNav)
const handleNavigateToConversationItemRef = useRef(handleNavigateToConversationItem)
handleNavigateToConversationItemRef.current = handleNavigateToConversationItem
useEffect(() => {
if (trajectoryNav?.to !== 'chat') return
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
consumeTrajectoryNav(trajectoryNav.nonce)
const items = conversationFindRenderItemsRef.current
const { index, toolUseId } = findChatRenderTarget(items, trajectoryNav.target)
if (index < 0) return
const key = getRenderItemKey(items[index]!)
handleNavigateToConversationItemRef.current(
{ id: key, renderItemKey: key, renderIndex: index, turnNumber: 0, preview: '', attachmentCount: 0 },
{ toolUseId },
)
})
})
return () => cancelAnimationFrame(frame)
}, [consumeTrajectoryNav, trajectoryNav])
useEffect(() => {
if (!resolvedSessionId || resolvedSessionId !== activeTabId) return
@@ -3556,6 +3606,13 @@ export function MessageList({
}, [isWorkspacePanelOpen, resolvedSessionId, restoreWorkspaceOrigin, workspacePanelOrigin])
/** Only the item a jump landed on hears about it, so other memoized items keep their props. */
const navigationToolUseIdFor = (item: RenderItem): string | undefined => (
highlightedNavigationToolUseId !== null && highlightedNavigationItemKey === getRenderItemKey(item)
? highlightedNavigationToolUseId
: undefined
)
const renderTranscriptItem = (item: RenderItem, index: number) => {
const cardsForItem = turnCardsByRenderIndex.get(index) ?? []
@@ -3584,6 +3641,7 @@ export function MessageList({
// whether a run stands open.
isLive={chatState !== 'idle' && index === renderItems.length - 1 && !hasTrailingStreamingItem}
disclosureKey={getRenderItemKey(item)}
revealToolUseId={navigationToolUseIdFor(item)}
/>
) : item.kind === 'team_card' ? (
resolvedSessionId ? (() => {
@@ -3625,6 +3683,10 @@ export function MessageList({
isTurnOutputOwner={turnOutputOwnerIndexes.has(index)}
turnCompletion={turnCompletionByMessageId.get(item.message.id)}
supersededAskUserQuestionIds={supersededAskUserQuestionIds}
navigationHighlighted={
item.message.type === 'tool_use' &&
navigationToolUseIdFor(item) === item.message.toolUseId
}
/>
)}
</RenderItemBoundary>
@@ -3871,6 +3933,7 @@ export const MessageBlock = memo(function MessageBlock({
isTurnOutputOwner,
turnCompletion,
supersededAskUserQuestionIds,
navigationHighlighted = false,
}: {
sessionId?: string | null
message: UIMessage
@@ -3888,6 +3951,8 @@ export const MessageBlock = memo(function MessageBlock({
isTurnOutputOwner?: boolean
turnCompletion?: TurnCompletion
supersededAskUserQuestionIds?: ReadonlySet<string>
/** A "locate in chat" jump landed on this tool call. */
navigationHighlighted?: boolean
}) {
const t = useTranslation()
const teammateVisual = message.type === 'user_text' && message.teammateFrom && team
@@ -3976,6 +4041,8 @@ export const MessageBlock = memo(function MessageBlock({
status={message.status}
partialInput={message.partialInput}
disclosureKey={message.toolUseId}
toolUseId={message.parentToolUseId ? undefined : message.toolUseId}
navigationHighlighted={navigationHighlighted}
agentTaskNotification={
message.toolName === 'Agent'
? agentTaskNotifications[message.toolUseId]
+137 -92
View File
@@ -14,6 +14,7 @@ import { InlineImageGallery } from './InlineImageGallery'
import { ToolResultImages } from './ToolResultImages'
import { ImageGenerationBlock } from './ImageGenerationBlock'
import { isImageGenerationToolName } from './imageGenerationTools'
import { ViewInTrajectoryButton } from '../trajectory/TrajectoryLinkContext'
import type { AgentTaskNotification } from '../../types/chat'
import {
PlanPreviewCard,
@@ -44,6 +45,10 @@ type Props = {
durationMs?: number
/** Stable key that survives virtualized row unmount/remount. */
disclosureKey?: string
/** Enables "view in trajectory" when rendered inside the main session's message list. */
toolUseId?: string
/** Briefly marks this call as the one a "locate in chat" jump landed on. */
navigationHighlighted?: boolean
}
const TOOL_ICONS: Record<string, string> = {
@@ -136,7 +141,7 @@ type ContentStats = {
windowed?: boolean
}
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, chrome = 'card', isPending = false, status, partialInput, defaultExpanded = false, durationMs, disclosureKey }: Props) {
export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, result, compact = false, chrome = 'card', isPending = false, status, partialInput, defaultExpanded = false, durationMs, disclosureKey, toolUseId, navigationHighlighted = false }: Props) {
const isRow = chrome === 'row'
const isExitPlanTool = isExitPlanModeTool(toolName)
const isEnterPlanTool = isEnterPlanModeTool(toolName)
@@ -172,6 +177,9 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
[isPending, obj, partialInput, toolName],
)
const liveStatsSummary = liveStats ? formatContentStats(liveStats, t) : ''
const pendingTitle = pendingSummary
? (liveStatsSummary ? `${pendingSummary} · ${liveStatsSummary}` : pendingSummary)
: undefined
// The text extractors below skip image blocks; this is what gives them a thumbnail.
const toolImages = useMemo(
() => toolResultImagesFor({ toolName, input, content: result?.content }),
@@ -241,24 +249,25 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
return (
<div
data-tool-call-chrome={chrome}
className={
data-tool-use-id={toolUseId}
className={`${
isRow
? ''
? 'rounded-[var(--radius-md)]'
: `overflow-hidden rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-lowest)] ${
compact ? 'mb-0' : 'mb-2'
}`
}
}${navigationHighlighted ? ' chat-tool-navigation-target' : ''}`}
>
<button
type="button"
data-chat-disclosure="true"
aria-expanded={expandable ? expanded : undefined}
onClick={() => {
if (expandable) {
setExpanded((value) => !value)
}
}}
className={`flex items-center text-left transition-colors hover:bg-[var(--color-surface-hover)] focus:outline-none focus-visible:shadow-[var(--shadow-focus-ring)] ${
{/*
The header is a row of siblings, not one big button, so a second action
("view in trajectory") can sit in it without nesting interactive
elements or being laid over the duration and chevron. The disclosure
button stretches its hit area across the whole header with an `::after`
overlay: clicking the duration or chevron still toggles, and the action
— positioned, later in tree order — paints and hit-tests above it.
*/}
<div
className={`group/toolhead relative flex items-center transition-colors hover:bg-[var(--color-surface-hover)] ${
isRow
// The negative margin lets the hover highlight breathe past the
// timeline rule without the row itself being inset from it.
@@ -266,84 +275,112 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
: compact ? 'w-full gap-[11px] px-3.5 py-2.5' : 'w-full gap-3 px-4 py-3'
}`}
>
{isRow ? (
/* Rows had no icon at all, so every step began with a bare word and
the eye had nothing to run down. A leading glyph gives the run a
left edge, separates one tool family from the next at a glance, and
marks the whole line as machinery rather than speech. */
<RowToolIcon toolName={toolName} active={Boolean(pendingSummary)} />
) : compact ? (
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">{icon}</span>
) : (
/* The ink square is the design's tool badge: solid `--t1` with the page
ground as its glyph color, which is exactly the primary-button pair. */
<span className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[8px] bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)]">
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</span>
)}
<span className={
isRow
// The running step is the one the reader is waiting on, so it is the
// one that gets colour. Finished steps stay grey and the eye lands on
// where the run actually is without a separate progress strip.
//
// Grey even when finished, and a step below the prose it sits among:
// the layers have to be separable at a glance, or a run of machinery
// reads as loudly as the sentence that concludes it.
? `shrink-0 text-[12.5px] font-medium ${
pendingSummary ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'
}`
: `shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`
}>
{toolName}
</span>
{filePath ? (
<span className={`min-w-0 flex-1 truncate font-mono ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
{filePath.split('/').pop()}
</span>
) : summary ? (
<span className={`min-w-0 flex-1 truncate ${summaryIsProse ? '' : 'font-mono'} ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
{summary}
</span>
) : (
<span className="flex-1" />
)}
{pendingSummary ? (
<span
className="inline-flex min-w-0 max-w-[58%] shrink-0 items-center gap-1 text-[12.5px] text-[var(--color-text-tertiary)]"
title={liveStatsSummary ? `${pendingSummary} · ${liveStatsSummary}` : pendingSummary}
>
<LoaderCircle size={13} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
<span className="truncate">{pendingSummary}</span>
{liveStatsSummary ? (
<>
<span className="shrink-0">·</span>
<span className="shrink-0 font-mono tabular-nums">
{liveStatsSummary}
</span>
</>
) : null}
</span>
) : stoppedSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[12.5px] text-[var(--color-text-tertiary)]">
<CircleStop size={13} strokeWidth={2.25} aria-hidden="true" />
{stoppedSummary}
</span>
) : result && outputSummary ? (
<span
className={`inline-flex min-w-0 shrink items-center gap-1.5 text-[12.5px] ${
result.isError
? 'font-medium text-[var(--color-error)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{result.isError && <CircleX size={13} strokeWidth={2} className="shrink-0" aria-hidden="true" />}
<span className="min-w-0 truncate">{outputSummary}</span>
</span>
) : liveStatsSummary ? (
<span className="shrink-0 font-mono text-[12.5px] tabular-nums text-[var(--color-text-tertiary)]">
{liveStatsSummary}
<button
type="button"
data-chat-disclosure="true"
aria-expanded={expandable ? expanded : undefined}
onClick={() => {
if (expandable) {
setExpanded((value) => !value)
}
}}
// The overlay covers the status text too, so its own tooltip cannot
// show; the running summary is the one that truncates, so carry it here.
title={pendingTitle}
className={`flex min-w-0 flex-1 items-center self-stretch text-left after:absolute after:inset-0 after:content-[''] focus:outline-none focus-visible:after:shadow-[var(--shadow-focus-ring)] ${
isRow
? 'gap-2 after:rounded-[var(--radius-md)]'
: compact ? 'gap-[11px]' : 'gap-3'
}`}
>
{isRow ? (
/* Rows had no icon at all, so every step began with a bare word and
the eye had nothing to run down. A leading glyph gives the run a
left edge, separates one tool family from the next at a glance, and
marks the whole line as machinery rather than speech. */
<RowToolIcon toolName={toolName} active={Boolean(pendingSummary)} />
) : compact ? (
<span className="material-symbols-outlined shrink-0 text-[16px] text-[var(--color-text-secondary)]">{icon}</span>
) : (
/* The ink square is the design's tool badge: solid `--t1` with the page
ground as its glyph color, which is exactly the primary-button pair. */
<span className="flex h-[30px] w-[30px] shrink-0 items-center justify-center rounded-[8px] bg-[var(--color-btn-primary-bg)] text-[var(--color-btn-primary-fg)]">
<span className="material-symbols-outlined text-[16px]">{icon}</span>
</span>
)}
<span className={
isRow
// The running step is the one the reader is waiting on, so it is the
// one that gets colour. Finished steps stay grey and the eye lands on
// where the run actually is without a separate progress strip.
//
// Grey even when finished, and a step below the prose it sits among:
// the layers have to be separable at a glance, or a run of machinery
// reads as loudly as the sentence that concludes it.
? `shrink-0 text-[12.5px] font-medium ${
pendingSummary ? 'text-[var(--color-brand)]' : 'text-[var(--color-text-tertiary)]'
}`
: `shrink-0 font-bold text-[var(--color-text-primary)] ${compact ? 'text-[13px]' : 'text-[14px]'}`
}>
{toolName}
</span>
{filePath ? (
<span className={`min-w-0 flex-1 truncate font-mono ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
{filePath.split('/').pop()}
</span>
) : summary ? (
<span className={`min-w-0 flex-1 truncate ${summaryIsProse ? '' : 'font-mono'} ${isRow ? 'text-[12px] text-[var(--color-text-tertiary)]' : compact ? 'text-[12.5px] text-[var(--color-text-secondary)]' : 'text-[13px] text-[var(--color-text-secondary)]'}`}>
{summary}
</span>
) : (
<span className="flex-1" />
)}
{pendingSummary ? (
<span
className="inline-flex min-w-0 max-w-[58%] shrink-0 items-center gap-1 text-[12.5px] text-[var(--color-text-tertiary)]"
>
<LoaderCircle size={13} strokeWidth={2.4} className="animate-spin" aria-hidden="true" />
<span className="truncate">{pendingSummary}</span>
{liveStatsSummary ? (
<>
<span className="shrink-0">·</span>
<span className="shrink-0 font-mono tabular-nums">
{liveStatsSummary}
</span>
</>
) : null}
</span>
) : stoppedSummary ? (
<span className="inline-flex shrink-0 items-center gap-1 text-[12.5px] text-[var(--color-text-tertiary)]">
<CircleStop size={13} strokeWidth={2.25} aria-hidden="true" />
{stoppedSummary}
</span>
) : result && outputSummary ? (
<span
className={`inline-flex min-w-0 shrink items-center gap-1.5 text-[12.5px] ${
result.isError
? 'font-medium text-[var(--color-error)]'
: 'text-[var(--color-text-tertiary)]'
}`}
>
{result.isError && <CircleX size={13} strokeWidth={2} className="shrink-0" aria-hidden="true" />}
<span className="min-w-0 truncate">{outputSummary}</span>
</span>
) : liveStatsSummary ? (
<span className="shrink-0 font-mono text-[12.5px] tabular-nums text-[var(--color-text-tertiary)]">
{liveStatsSummary}
</span>
) : null}
</button>
{toolUseId ? (
// Its own slot left of the duration: the space is always held (no
// jump on hover) and nothing is drawn over the duration or chevron.
// `relative` lifts it above the disclosure's stretched overlay; the
// negative margin keeps a 24px target from growing a 20px row.
<ViewInTrajectoryButton
toolUseId={toolUseId}
className="relative -my-1 shrink-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/toolhead:opacity-100"
/>
) : null}
{durationSummary && (
<span className={`shrink-0 font-mono text-[12px] tabular-nums ${
@@ -357,7 +394,7 @@ export const ToolCallBlock = memo(function ToolCallBlock({ toolName, input, resu
{isRow ? (expanded ? '▾' : '▸') : (expanded ? '▴' : '▾')}
</span>
)}
</button>
</div>
{SESSION_TOOL_NAMES.has(toolName) ? <SessionToolLinks input={input} result={result?.content} /> : null}
@@ -653,6 +690,8 @@ function ShellOutput({ content, isError, toolName }: { content: unknown; isError
</span>
<CopyButton
text={output.full}
label={t('common.copy')}
copiedLabel={t('common.copied')}
className="rounded-[var(--radius-sm)] px-2 py-0.5 text-[11px] text-[var(--color-terminal-muted)] transition-colors hover:text-[var(--color-terminal-fg)]"
/>
</div>
@@ -824,6 +863,8 @@ function renderResultOutput(
<span>{label}</span>
<CopyButton
text={text}
label={t?.('common.copy')}
copiedLabel={t?.('common.copied')}
className="rounded-[var(--radius-sm)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-error)] transition-colors hover:bg-[var(--color-error-soft-hover)]"
/>
</div>
@@ -853,6 +894,8 @@ function renderResultOutput(
<span>{label}</span>
<CopyButton
text={text}
label={t?.('common.copy')}
copiedLabel={t?.('common.copied')}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
@@ -911,6 +954,8 @@ function renderDetails(
<span>{label}</span>
<CopyButton
text={text}
label={t?.('common.copy')}
copiedLabel={t?.('common.copied')}
className="rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-1 text-[11px] normal-case tracking-normal text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]"
/>
</div>
@@ -108,6 +108,8 @@ type Props = {
isLive?: boolean
/** Stable key that survives virtualized row unmount/remount. */
disclosureKey?: string
/** A "locate in chat" jump targets this call: open its run and mark its row. */
revealToolUseId?: string
}
export type OpenAgentRunPayload = {
@@ -131,6 +133,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
isStreaming,
isLive = false,
disclosureKey,
revealToolUseId,
}: Props) {
const resolvedSteps = useMemo(() => steps ?? toActivitySteps(toolCalls), [steps, toolCalls])
const memoryActivity = getMemoryToolActivity(toolCalls, resultMap)
@@ -164,6 +167,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
showOpenRun={showOpenRun}
isStreaming={isStreaming}
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
/>
) : null}
</div>
@@ -185,6 +189,7 @@ export const ToolCallGroup = memo(function ToolCallGroup({
isStreaming={isStreaming}
isLive={isLive}
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
/>
)
})
@@ -205,6 +210,7 @@ function ToolCallGroupContent({
isStreaming,
isLive = false,
disclosureKey,
revealToolUseId,
}: ContentProps) {
const toolCalls = activityStepToolCalls(steps)
const hasImageGeneration = toolCalls.some((toolCall) => isImageGenerationToolName(toolCall.toolName))
@@ -262,6 +268,7 @@ function ToolCallGroupContent({
activeThinkingId={activeThinkingId}
showOpenRun={showOpenRun}
isStreaming={isStreaming}
revealToolUseId={revealToolUseId}
/>
))}
</div>
@@ -316,6 +323,7 @@ function ToolCallGroupContent({
isStreaming={isStreaming}
isLive={isLive}
disclosureKey={disclosureKey}
revealToolUseId={revealToolUseId}
/>
)
}
@@ -804,6 +812,7 @@ function ToolCallTree({
status={toolCall.status}
partialInput={toolCall.partialInput}
durationMs={toolCallDurationMs(toolCall, result)}
toolUseId={toolCall.parentToolUseId ? undefined : toolCall.toolUseId}
/>
{childToolCalls.length > 0 && (
<div className={
@@ -16,6 +16,29 @@ describe('chat blocks', () => {
useChatStore.setState({ sessions: {} })
})
it.each(['card', 'row'] as const)('marks a located %s by its tool id, on the chrome the highlight style keys off', (chrome) => {
const { container, rerender } = render(
<ToolCallBlock chrome={chrome} toolName="Bash" input={{ command: 'ls' }} result={{ content: 'a', isError: false }} toolUseId="toolu_mark" navigationHighlighted />,
)
const root = container.querySelector('[data-tool-use-id="toolu_mark"]')!
expect(root.getAttribute('data-tool-call-chrome')).toBe(chrome)
expect(root.className).toContain('chat-tool-navigation-target')
rerender(<ToolCallBlock chrome={chrome} toolName="Bash" input={{ command: 'ls' }} result={{ content: 'a', isError: false }} toolUseId="toolu_mark" />)
expect(container.querySelector('[data-tool-use-id="toolu_mark"]')!.className).not.toContain('chat-tool-navigation-target')
})
it.each(['card', 'row'] as const)('names the %s output copy action in the interface language', (chrome) => {
// Regression: tool output copy buttons fell back to CopyButton's English "Copy".
useSettingsStore.setState({ locale: 'zh' })
const { container } = render(
<ToolCallBlock chrome={chrome} toolName="Bash" input={{ command: 'ls' }} result={{ content: 'a.txt', isError: false }} />,
)
fireEvent.click(container.querySelector('[data-chat-disclosure="true"]')!)
expect(screen.getAllByRole('button', { name: '复制' }).length).toBeGreaterThan(0)
expect(screen.queryByRole('button', { name: 'Copy' })).toBeNull()
})
it('keeps thinking collapsed by default', () => {
const { container } = render(<ThinkingBlock content="this is a long internal reasoning trace" isActive />)
@@ -18,7 +18,6 @@ const mocks = vi.hoisted(() => ({
connectToSession: vi.fn(),
setActiveTab: vi.fn(),
openTab: vi.fn(),
openTraceTab: vi.fn(),
getDesktopUiPreferences: vi.fn(),
updatePetPreferences: vi.fn(),
tabState: {
@@ -66,7 +65,6 @@ vi.mock('../../stores/tabStore', () => {
activeTabId: mocks.tabState.activeTabId,
tabs: mocks.tabState.tabs,
openTab: mocks.openTab,
openTraceTab: mocks.openTraceTab,
setActiveTab: mocks.setActiveTab,
})
useTabStore.setState = (next: { activeTabId?: string | null }) => {
@@ -145,14 +143,6 @@ vi.mock('./H5ConnectionView', () => ({
),
}))
vi.mock('../../pages/TraceSession', () => ({
TraceSession: ({ sessionId, standalone }: { sessionId: string; standalone?: boolean }) => (
<section data-standalone={standalone ? 'true' : 'false'} data-testid="trace-session">
trace:{sessionId}
</section>
),
}))
vi.mock('@/components/layout/Toast', () => ({
ToastContainer: () => null,
}))
@@ -209,7 +199,6 @@ describe('AppShell boot flow', () => {
},
})
mocks.openTab.mockReset()
mocks.openTraceTab.mockReset()
mocks.setActiveTab.mockImplementation((sessionId: string) => {
mocks.tabState.activeTabId = sessionId
})
@@ -389,51 +378,16 @@ describe('AppShell boot flow', () => {
})
})
it('opens a trace tab from a session-scoped trace deep link', async () => {
window.history.pushState({}, '', '/?traceSessionId=session-deep-link')
render(<AppShell />)
await screen.findByText('sidebar loaded')
await waitFor(() => {
// No session in the store yet, so the id prefix is all the title we have.
expect(mocks.openTraceTab).toHaveBeenCalledWith('session-deep-link', 'session-')
})
expect(mocks.connectToSession).not.toHaveBeenCalled()
})
it('titles a deep-linked trace tab with the session once the store knows it', async () => {
useSessionStore.setState({
sessions: [{
id: 'session-deep-link',
title: 'Debug stuck agent',
createdAt: '2026-06-09T10:00:00.000Z',
modifiedAt: '2026-06-09T10:10:00.000Z',
messageCount: 2,
projectPath: '/tmp',
workDir: '/tmp',
workDirExists: true,
}],
})
window.history.pushState({}, '', '/?traceSessionId=session-deep-link')
render(<AppShell />)
await screen.findByText('sidebar loaded')
await waitFor(() => {
expect(mocks.openTraceTab).toHaveBeenCalledWith('session-deep-link', 'Debug stuck agent')
})
})
it('renders a dedicated trace window shell from traceWindow deep links', async () => {
it('boots the normal shell for a retired trace-window deep link', async () => {
// Trace windows and `?traceSessionId=` deep links were removed with the
// standalone Trace page; a stale URL must not hide the workspace shell.
window.history.pushState({}, '', '/?traceWindow=1&traceSessionId=session-window')
render(<AppShell />)
expect(await screen.findByTestId('trace-session')).toHaveTextContent('trace:session-window')
expect(screen.getByTestId('trace-session')).toHaveAttribute('data-standalone', 'true')
expect(screen.queryByText('sidebar loaded')).not.toBeInTheDocument()
expect(mocks.restoreTabs).not.toHaveBeenCalled()
await screen.findByText('sidebar loaded')
await waitFor(() => expect(mocks.restoreTabs).toHaveBeenCalledTimes(1))
expect(mocks.openTab).not.toHaveBeenCalled()
})
it('routes native menu navigation through the desktop host', async () => {
+17 -50
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState, type HTMLAttributes } from 'react'
import { useCallback, useEffect, useRef, useState, type HTMLAttributes } from 'react'
import { Sidebar } from './Sidebar'
import { ContentRouter } from './ContentRouter'
import { ToastContainer } from '@/components/layout/Toast'
@@ -37,10 +37,6 @@ import { useTranslation } from '../../i18n'
import { H5ConnectionView } from './H5ConnectionView'
import { useMobileViewport } from '../../hooks/useMobileViewport'
import type { Tab } from '../../stores/tabStore'
import { getTraceLaunchRequest } from '../../lib/traceLaunch'
import { openTraceDetail } from '../../lib/traceNavigation'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
function isChatTab(tab: Tab | undefined) {
return tab?.type === 'session'
@@ -63,7 +59,6 @@ export function AppShell() {
setDesktopUiPreferencesRequest((current) => current === request ? null : current)
}, [])
const t = useTranslation()
const traceLaunch = useMemo(() => getTraceLaunchRequest(), [])
const desktopRuntime = isDesktopRuntime()
const isMobileShell = useMobileViewport() && !desktopRuntime
const tabs = useTabStore((s) => s.tabs)
@@ -130,42 +125,27 @@ export function AppShell() {
await fetchSettings()
if (cancelled) return
if (!traceLaunch.windowMode) {
const displayNameHydrationRevision = captureProjectDisplayNameHydrationRevision()
const preferencesRequest = desktopUiPreferencesApi.getPreferences()
setDesktopUiPreferencesRequest(preferencesRequest)
void preferencesRequest
.then(({ preferences }) => {
if (cancelled) return
hydrateProjectDisplayNames(
preferences.projectDisplayNames ?? {},
displayNameHydrationRevision,
)
if (desktopRuntime && preferences.pet.enabled) {
return getDesktopHost().pets.show()
}
})
.catch(() => undefined)
}
const displayNameHydrationRevision = captureProjectDisplayNameHydrationRevision()
const preferencesRequest = desktopUiPreferencesApi.getPreferences()
setDesktopUiPreferencesRequest(preferencesRequest)
void preferencesRequest
.then(({ preferences }) => {
if (cancelled) return
hydrateProjectDisplayNames(
preferences.projectDisplayNames ?? {},
displayNameHydrationRevision,
)
if (desktopRuntime && preferences.pet.enabled) {
return getDesktopHost().pets.show()
}
})
.catch(() => undefined)
setReady(true)
void (async () => {
if (traceLaunch.windowMode) return
await useTabStore.getState().restoreTabs()
if (cancelled) return
if (traceLaunch.sessionId) {
// A deep link arrives before the session list is in the store often
// enough that the id prefix is the only title we can guarantee.
const launchedSession = useSessionStore.getState().sessions
.find((session) => session.id === traceLaunch.sessionId)
openTraceDetail(
traceLaunch.sessionId,
launchedSession?.title || traceLaunch.sessionId.slice(0, 8),
)
return
}
const { activeTabId: activeId, tabs } = useTabStore.getState()
const activeTab = tabs.find((tab) => tab.sessionId === activeId)
if (activeId && activeTab?.type === 'session') {
@@ -191,7 +171,7 @@ export function AppShell() {
return () => {
cancelled = true
}
}, [bootstrapNonce, fetchSettings, desktopRuntime, traceLaunch])
}, [bootstrapNonce, fetchSettings, desktopRuntime])
// Listen for macOS native menu navigation events (About / Settings)
useEffect(() => {
@@ -299,19 +279,6 @@ export function AppShell() {
)
}
if (traceLaunch.windowMode) {
return (
<div className="app-shell-viewport flex overflow-hidden bg-[var(--color-surface)] text-[var(--color-text-primary)]">
{traceLaunch.sessionId ? (
<TraceSession sessionId={traceLaunch.sessionId} standalone />
) : (
<TraceList />
)}
<ToastContainer />
</div>
)
}
return (
<div className={`app-shell app-shell-viewport flex overflow-hidden bg-[var(--color-surface)]${isMobileShell ? ' app-shell--mobile' : ''}`}>
{isMobileShell && effectiveSidebarOpen ? (
@@ -32,19 +32,6 @@ vi.mock('../../pages/TerminalSettings', () => ({
),
}))
vi.mock('../../pages/TraceSession', () => ({
TraceSession: ({ sessionId, onBack }: { sessionId: string; onBack?: () => void }) => (
<div data-testid="trace-session">
trace:{sessionId}
{onBack ? <button type="button" onClick={onBack}>back</button> : null}
</div>
),
}))
vi.mock('../../pages/TraceList', () => ({
TraceList: () => <div data-testid="trace-list" />,
}))
vi.mock('../../pages/SubagentRunPage', () => ({
SubagentRunPage: ({ sourceSessionId, taskId, toolUseId, title }: { sourceSessionId: string; taskId?: string; toolUseId: string; title: string }) => (
<div data-testid="subagent-run-page">{sourceSessionId}:{toolUseId}:{taskId}:{title}</div>
@@ -159,65 +146,6 @@ describe('ContentRouter tab surfaces', () => {
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === useTabStore.getState().activeTabId)?.terminalCwd).toBe('/tmp/project')
})
it('renders trace tabs without mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{
sessionId: '__trace__session-1',
title: 'Trace',
type: 'trace',
status: 'idle',
traceSessionId: 'session-1',
}],
activeTabId: '__trace__session-1',
})
render(<ContentRouter />)
expect(screen.getByTestId('trace-session')).toHaveTextContent('trace:session-1')
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('walks a trace tab back to the list and closes the tab behind it', () => {
useTabStore.setState({
tabs: [
{ sessionId: 'session-1', title: 'Chat', type: 'session', status: 'idle' },
{
sessionId: '__trace__session-1',
title: 'Chat',
type: 'trace',
status: 'idle',
traceSessionId: 'session-1',
},
],
activeTabId: '__trace__session-1',
})
render(<ContentRouter />)
fireEvent.click(screen.getByRole('button', { name: 'back' }))
const { tabs, activeTabId } = useTabStore.getState()
expect(activeTabId).toBe(SETTINGS_TAB_ID)
expect(tabs.some((tab) => tab.sessionId === '__trace__session-1')).toBe(false)
expect(useUIStore.getState().pendingSettingsTab).toBe('trace')
})
it('renders the trace list tab without mounting the chat session surface', () => {
useTabStore.setState({
tabs: [{
sessionId: '__traces__',
title: 'Trace',
type: 'traces',
status: 'idle',
}],
activeTabId: '__traces__',
})
render(<ContentRouter />)
expect(screen.getByTestId('trace-list')).toBeInTheDocument()
expect(screen.queryByTestId('active-session')).not.toBeInTheDocument()
})
it('renders SubAgent run tabs', () => {
useTabStore.setState({
tabs: [{
@@ -6,12 +6,9 @@ import { ScheduledTasks } from '../../pages/ScheduledTasks'
import { ExtensionMarket } from '@/pages/ExtensionMarket'
import { Settings } from '../../pages/Settings'
import { TerminalSettings } from '../../pages/TerminalSettings'
import { TraceList } from '../../pages/TraceList'
import { TraceSession } from '../../pages/TraceSession'
import { SubagentRunPage, TeamMemberRunPage } from '../../pages/SubagentRunPage'
import { AgentTeamsWorkbenchTab } from '../agentTeams/AgentTeamsWorkbenchTab'
import { WorkspaceBrowserGuestLayer } from '../workbench/WorkspaceBrowserGuestLayer'
import { returnToTraceList } from '../../lib/traceNavigation'
export function ContentRouter() {
const activeTabId = useTabStore((s) => s.activeTabId)
@@ -51,14 +48,6 @@ export function ContentRouter() {
page = <ScheduledTasks />
} else if (activeTabType === 'connectors' || activeTabType === 'market') {
page = <ExtensionMarket />
} else if (activeTabType === 'trace') {
const traceTabId = activeTabId
const traceSessionId = tabs.find((t) => t.sessionId === traceTabId)?.traceSessionId
page = traceSessionId
? <TraceSession sessionId={traceSessionId} onBack={() => returnToTraceList(traceTabId)} />
: <EmptySession />
} else if (activeTabType === 'traces') {
page = <TraceList />
} else if (activeTabType === 'subagent') {
const subagentTab = tabs.find((t) => t.sessionId === activeTabId)
page = subagentTab?.sourceSessionId && subagentTab.subagentToolUseId
+1 -8
View File
@@ -9,8 +9,6 @@ import {
TEAM_MEMBER_TAB_PREFIX,
TEAM_TAB_PREFIX,
TERMINAL_TAB_PREFIX,
TRACE_LIST_TAB_ID,
TRACE_TAB_PREFIX,
WORKBENCH_TAB_PREFIX,
useTabStore,
type Tab,
@@ -84,16 +82,13 @@ function clippedSide(
// One glyph per *non-chat* tab kind: the glyph says "this tab is not a
// conversation". Chat tabs deliberately have none — a bubble on every tab in a
// strip that is mostly chats is pure noise, and the slot it occupied is worth
// more as title. `trace` and `traces` share the Settings rail's glyph on
// purpose — a trace tab should read as that section, not as another chat.
// more as title.
const TAB_TYPE_ICON: Partial<Record<TabType, string>> = {
settings: 'settings',
scheduled: 'schedule',
market: 'storefront',
connectors: 'link',
terminal: 'terminal',
trace: 'account_tree',
traces: 'account_tree',
workbench: 'view_sidebar',
subagent: 'smart_toy',
team: 'account_tree',
@@ -123,9 +118,7 @@ function isSessionTabId(tabId: string | null) {
tabId !== SCHEDULED_TAB_ID &&
tabId !== MARKET_TAB_ID &&
tabId !== CONNECTORS_TAB_ID &&
tabId !== TRACE_LIST_TAB_ID &&
!tabId.startsWith(TERMINAL_TAB_PREFIX) &&
!tabId.startsWith(TRACE_TAB_PREFIX) &&
!tabId.startsWith(WORKBENCH_TAB_PREFIX) &&
!tabId.startsWith(SUBAGENT_TAB_PREFIX) &&
!tabId.startsWith(TEAM_TAB_PREFIX) &&
@@ -1,182 +0,0 @@
import type { ReactNode } from 'react'
import {
AlertTriangle,
Bot,
CircleDot,
Clock3,
FileJson2,
GitBranch,
MessageSquareText,
RadioTower,
Sparkles,
Wrench,
} from 'lucide-react'
import { Badge } from '@/components/ui/Badge'
import { useTranslation } from '../../i18n'
import type { TraceSpan, TraceSpanStatus } from '../../lib/traceViewModel'
type TraceTranslator = ReturnType<typeof useTranslation>
export function TypeIcon({ span, size = 14 }: { span: TraceSpan; size?: number }) {
const { icon, className } = iconForSpan(span, size)
return (
<span className={`inline-flex shrink-0 items-center justify-center ${className}`} aria-hidden="true">
{icon}
</span>
)
}
/**
* Three tiers of ink, so a glance at the timeline separates the model from
* everything else: clay for LLM calls, secondary ink for content (messages,
* tool invocations), tertiary for structure and lifecycle.
*
* The spec calls the message icons "blue-grey", but `--color-info` resolves to
* the same clay as `--color-brand` in five of the six themes, which would erase
* the LLM signal. Secondary ink is the closest thing that holds in all six.
*/
function iconForSpan(span: TraceSpan, size: number): { icon: ReactNode; className: string } {
const tertiary = 'text-[var(--color-text-tertiary)]'
const secondary = 'text-[var(--color-text-secondary)]'
switch (span.kind) {
case 'llm':
return { icon: <Sparkles size={size} strokeWidth={2} />, className: 'text-[var(--color-brand)]' }
case 'tool':
return { icon: <Wrench size={size} strokeWidth={2} />, className: secondary }
case 'tool_result':
return { icon: <Wrench size={size} strokeWidth={2} />, className: tertiary }
case 'turn':
return { icon: <GitBranch size={size} strokeWidth={2} />, className: tertiary }
case 'session':
return { icon: <RadioTower size={size} strokeWidth={2} />, className: tertiary }
case 'event':
return span.status === 'error'
? { icon: <AlertTriangle size={size} strokeWidth={2} />, className: 'text-[var(--color-error)]' }
: { icon: <CircleDot size={size} strokeWidth={2} />, className: tertiary }
case 'message':
if (span.message?.type === 'assistant') {
return { icon: <Bot size={size} strokeWidth={2} />, className: secondary }
}
if (span.message?.type === 'system') {
return { icon: <FileJson2 size={size} strokeWidth={2} />, className: tertiary }
}
return { icon: <MessageSquareText size={size} strokeWidth={2} />, className: secondary }
default:
return { icon: <FileJson2 size={size} strokeWidth={2} />, className: tertiary }
}
}
export function StatusGlyph({ status }: { status: TraceSpanStatus }) {
if (status === 'error') {
return <AlertTriangle size={13} strokeWidth={2} className="shrink-0 text-[var(--color-error)]" aria-hidden="true" />
}
if (status === 'pending') {
return <Clock3 size={13} strokeWidth={2} className="shrink-0 animate-pulse-dot text-[var(--color-warning)]" aria-hidden="true" />
}
return null
}
export function StatusPill({ status }: { status: TraceSpanStatus }) {
const t = useTranslation()
const tone = status === 'error' ? 'danger' : status === 'pending' ? 'warning' : 'success'
const label = status === 'error'
? t('trace.status.error')
: status === 'pending'
? t('trace.status.pending')
: t('trace.status.ok')
return <Badge tone={tone} size="sm" pill={false}>{label}</Badge>
}
export function MetaChip({
label,
value,
tone = 'default',
title,
}: {
label: string
value: string
tone?: 'default' | 'danger'
title?: string
}) {
return (
<span
className="inline-flex min-w-0 items-baseline gap-1.5 text-[12.5px]"
{...(title ? { title } : {})}
>
<span className="shrink-0 text-[var(--color-text-secondary)]">{label}</span>
<span className={`truncate font-mono font-semibold ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>
{value}
</span>
</span>
)
}
/**
* The live chip. The breathing dot lives beside it in the header (and carries
* the health color), so this one is a plain tinted chip — two pulsing dots on
* the same line read as two different signals.
*/
export function LiveBadge() {
const t = useTranslation()
return (
<Badge tone="success" size="sm" pill={false}>
{t('trace.live')}
</Badge>
)
}
export function spanDisplayTitle(span: TraceSpan, t: TraceTranslator): string {
if (span.kind === 'message' && span.message) {
switch (span.message.type) {
case 'user': return t('trace.message.user')
case 'assistant': return t('trace.message.assistant')
case 'system': return t('trace.message.system')
case 'tool_use': return t('trace.message.toolRequest')
case 'tool_result': return t('trace.message.toolResult')
default: return span.message.type
}
}
if (span.kind === 'llm') {
return span.call?.model ?? span.call?.provider?.name ?? t('trace.modelCall')
}
if (span.kind === 'tool') {
return span.toolName ?? span.title
}
if (span.kind === 'tool_result') {
return span.status === 'error' ? t('trace.toolError') : t('trace.toolResult')
}
if (span.kind === 'event' && span.event) {
return traceEventPhaseLabel(span.event.phase, t)
}
if (span.kind === 'turn') {
return turnDisplayTitle(span.title, (span.turnIndex ?? 0) + 1, t)
}
return span.title
}
export function turnDisplayTitle(title: string, oneBasedIndex: number, t: TraceTranslator): string {
if (title === 'Session activity') return t('trace.sessionActivity')
const match = title.match(/^Turn (\d+)$/)
if (match) return t('trace.turnLabel', { index: match[1]! })
if (!title.trim()) return t('trace.turnLabel', { index: oneBasedIndex })
return title
}
export function traceEventPhaseLabel(phase: string, t: TraceTranslator): string {
switch (phase) {
case 'api_call_started': return t('trace.event.apiCallStarted')
case 'api_call_completed': return t('trace.event.apiCallCompleted')
case 'api_call_failed': return t('trace.event.apiCallFailed')
case 'api_call_aborted': return t('trace.event.apiCallAborted')
case 'response_capture_failed': return t('trace.event.responseCaptureFailed')
case 'upstream_fetch_started': return t('trace.event.upstreamFetchStarted')
case 'upstream_fetch_completed': return t('trace.event.upstreamFetchCompleted')
case 'upstream_fetch_failed': return t('trace.event.upstreamFetchFailed')
default:
return phase
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
}
@@ -1,208 +0,0 @@
import { useTranslation } from '../../i18n'
import type { TraceSpan, TraceViewModel } from '../../lib/traceViewModel'
import { formatTraceJson } from '../../lib/traceViewModel'
import { formatClockTime, formatDurationMs, formatTokenCount, formatUsageBrief } from '../../lib/trace/formatters'
import { formatBytes } from '../../lib/formatBytes'
import { CodeViewer } from '../chat/CodeViewer'
import { MetaChip, StatusPill, TypeIcon, spanDisplayTitle, traceEventPhaseLabel } from './TraceBadges'
import { Section } from './detail/Section'
import { LlmCallDetail } from './detail/LlmCallDetail'
import { ToolDetail } from './detail/ToolDetail'
import { MessageDetail } from './detail/MessageDetail'
import { SessionOverview } from './detail/SessionOverview'
export function TraceDetail({
span,
viewModel,
sessionId,
revisionKey,
overviewRevisionKey,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
sessionId: string
revisionKey?: string
overviewRevisionKey?: string
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
return (
<div className="flex min-h-0 flex-1 flex-col" data-testid="trace-detail">
<div className="shrink-0 border-b border-[var(--color-border)] px-6 py-4">
<div className="flex min-w-0 items-center gap-2.5">
<TypeIcon span={span} size={16} />
<h2
className="min-w-0 truncate text-[17px] font-bold text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{spanDisplayTitle(span, t)}
</h2>
<StatusPill status={span.status} />
</div>
<div className="mt-2.5 flex min-w-0 flex-wrap items-baseline gap-x-4 gap-y-1.5">
<HeaderChips span={span} />
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
{/* Left-aligned reading column, per the handoff's 960px detail width —
long JSON bodies are unreadable stretched across a wide monitor. */}
<div className="w-full max-w-[960px]">
<DetailBody
span={span}
viewModel={viewModel}
sessionId={sessionId}
revisionKey={revisionKey}
overviewRevisionKey={overviewRevisionKey}
onSelect={onSelect}
/>
</div>
</div>
</div>
)
}
function DetailBody({
span,
viewModel,
sessionId,
revisionKey,
overviewRevisionKey,
onSelect,
}: {
span: TraceSpan
viewModel: TraceViewModel
sessionId: string
revisionKey?: string
overviewRevisionKey?: string
onSelect: (spanId: string) => void
}) {
switch (span.kind) {
case 'llm':
return <LlmCallDetail sessionId={sessionId} span={span} revisionKey={revisionKey} />
case 'tool':
case 'tool_result':
return <ToolDetail span={span} />
case 'message':
return <MessageDetail span={span} />
case 'event':
return <EventDetail span={span} />
default:
return (
<SessionOverview
span={span}
viewModel={viewModel}
onSelect={onSelect}
sessionId={sessionId}
revisionKey={overviewRevisionKey ?? revisionKey}
/>
)
}
}
function HeaderChips({ span }: { span: TraceSpan }) {
const t = useTranslation()
const call = span.call
if (call) {
return (
<>
{call.model ? <MetaChip label={t('trace.model')} value={call.model} /> : null}
{call.provider?.name ? <MetaChip label={t('trace.provider')} value={call.provider.name} /> : null}
{span.durationMs !== undefined ? (
<MetaChip
label={span.status === 'pending' ? t('trace.elapsed') : t('trace.duration')}
value={formatDurationMs(span.durationMs)}
/>
) : null}
{call.usage ? (
<MetaChip
label={t('trace.tokens')}
value={formatUsageBrief(call.usage)}
title={usageTooltip(call.usage)}
/>
) : null}
<MetaChip label={t('trace.request')} value={formatBytes(call.request.body.bytes)} />
{call.response ? (
<>
<MetaChip label={t('trace.response')} value={formatBytes(call.response.body.bytes)} />
<MetaChip
label={t('trace.status')}
value={String(call.response.status)}
tone={call.response.status >= 400 ? 'danger' : 'default'}
/>
</>
) : null}
<MetaChip label={t('trace.started')} value={formatClockTime(call.startedAt)} />
{span.completedAt ? <MetaChip label={t('trace.completed')} value={formatClockTime(span.completedAt)} /> : null}
</>
)
}
return (
<>
{span.toolUseId ? <MetaChip label={t('trace.detail.toolUseId')} value={span.toolUseId} /> : null}
{span.durationMs !== undefined ? (
<MetaChip
label={durationLabelForSpan(span, t)}
value={formatDurationMs(span.durationMs)}
/>
) : null}
<MetaChip label={t('trace.started')} value={formatClockTime(span.timestamp)} />
{span.completedAt ? <MetaChip label={t('trace.completed')} value={formatClockTime(span.completedAt)} /> : null}
</>
)
}
function durationLabelForSpan(span: TraceSpan, t: ReturnType<typeof useTranslation>): string {
if (span.status === 'pending') return t('trace.elapsed')
if (span.kind === 'session' || span.kind === 'turn') return t('trace.wallTime')
return t('trace.duration')
}
function usageTooltip(usage: NonNullable<TraceSpan['tokenUsage']>): string {
const parts = [
`in ${formatTokenCount(usage.inputTokens)}`,
`out ${formatTokenCount(usage.outputTokens)}`,
]
if (usage.cacheReadInputTokens !== undefined) {
parts.push(`cache read ${formatTokenCount(usage.cacheReadInputTokens)}`)
}
if (usage.cacheCreationInputTokens !== undefined) {
parts.push(`cache write ${formatTokenCount(usage.cacheCreationInputTokens)}`)
}
return parts.join(' · ')
}
function EventDetail({ span }: { span: TraceSpan }) {
const t = useTranslation()
const event = span.event
if (!event) return null
return (
<div data-testid="trace-event-detail">
<Section sectionKey="event.detail" title={t('trace.section.event')} defaultOpen>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[12.5px]">
<dt className="text-[var(--color-text-tertiary)]">{t('trace.detail.phase')}</dt>
<dd className="min-w-0 truncate font-mono text-[var(--color-text-secondary)]">
{traceEventPhaseLabel(event.phase, t)}
</dd>
<dt className="text-[var(--color-text-tertiary)]">{t('trace.detail.severity')}</dt>
<dd className={`min-w-0 truncate font-mono ${event.severity === 'error' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-secondary)]'}`}>
{event.severity}
</dd>
{event.message ? (
<>
<dt className="text-[var(--color-text-tertiary)]">{t('trace.detail.message')}</dt>
<dd className="min-w-0 whitespace-pre-wrap break-words text-[var(--color-text-secondary)]">
{event.message}
</dd>
</>
) : null}
</dl>
</Section>
{event.metadata && Object.keys(event.metadata).length > 0 ? (
<Section sectionKey="event.metadata" title={t('trace.section.metadata')} defaultOpen>
<CodeViewer code={formatTraceJson(event.metadata)} language="json" maxLines={32} showLineNumbers />
</Section>
) : null}
</div>
)
}
@@ -1,95 +0,0 @@
import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react'
const STORAGE_KEY = 'trace.treeWidth'
const DEFAULT_WIDTH = 400
const MIN_WIDTH = 280
const MAX_WIDTH = 560
function clampWidth(width: number): number {
return Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, Math.round(width)))
}
function readStoredWidth(): number {
try {
const stored = window.localStorage.getItem(STORAGE_KEY)
const parsed = stored === null ? Number.NaN : Number.parseInt(stored, 10)
return Number.isFinite(parsed) ? clampWidth(parsed) : DEFAULT_WIDTH
} catch {
return DEFAULT_WIDTH
}
}
function persistWidth(width: number) {
try {
window.localStorage.setItem(STORAGE_KEY, String(width))
} catch {
// localStorage unavailable (private mode); keep the in-memory width.
}
}
export function TraceSplitLayout({ tree, detail }: { tree: ReactNode; detail: ReactNode }) {
const [width, setWidth] = useState(readStoredWidth)
const dragRef = useRef<{ startX: number; startWidth: number } | null>(null)
const widthRef = useRef(width)
widthRef.current = width
const onPointerMove = useCallback((event: MouseEvent) => {
const drag = dragRef.current
if (!drag) return
setWidth(clampWidth(drag.startWidth + (event.clientX - drag.startX)))
}, [])
const onPointerUp = useCallback(() => {
if (!dragRef.current) return
dragRef.current = null
persistWidth(widthRef.current)
document.body.style.removeProperty('cursor')
document.body.style.removeProperty('user-select')
window.removeEventListener('mousemove', onPointerMove)
window.removeEventListener('mouseup', onPointerUp)
}, [onPointerMove])
const onDividerMouseDown = useCallback((event: React.MouseEvent) => {
event.preventDefault()
dragRef.current = { startX: event.clientX, startWidth: widthRef.current }
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onPointerMove)
window.addEventListener('mouseup', onPointerUp)
}, [onPointerMove, onPointerUp])
const onDividerDoubleClick = useCallback(() => {
setWidth(DEFAULT_WIDTH)
persistWidth(DEFAULT_WIDTH)
}, [])
useEffect(() => () => {
window.removeEventListener('mousemove', onPointerMove)
window.removeEventListener('mouseup', onPointerUp)
}, [onPointerMove, onPointerUp])
return (
<div
className="flex min-h-0 flex-1 flex-col overflow-hidden lg:flex-row"
style={{ '--trace-tree-width': `${width}px` } as CSSProperties}
data-testid="trace-split-layout"
>
<div className="flex h-[40vh] min-h-0 shrink-0 flex-col overflow-hidden lg:h-auto lg:w-[var(--trace-tree-width)]">
{tree}
</div>
<div
role="separator"
aria-orientation="vertical"
data-testid="trace-split-divider"
onMouseDown={onDividerMouseDown}
onDoubleClick={onDividerDoubleClick}
className="group relative hidden w-px shrink-0 cursor-col-resize bg-[var(--color-border)] lg:block"
>
<div className="absolute inset-y-0 -left-[2px] w-[5px] transition-colors group-hover:bg-[var(--color-primary-fixed-dim)]" />
</div>
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden border-t border-[var(--color-border)] lg:border-t-0">
{detail}
</div>
</div>
)
}
-406
View File
@@ -1,406 +0,0 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { EmptyState } from '@/components/ui/EmptyState'
import { IconButton } from '@/components/ui/IconButton'
import { SearchField } from '@/components/ui/SearchField'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import { useTranslation } from '../../i18n'
import { previewTraceValue, type TraceSpan, type TraceViewModel } from '../../lib/traceViewModel'
import { formatDurationMs, formatTokenCount } from '../../lib/trace/formatters'
import { StatusGlyph, TypeIcon, spanDisplayTitle, turnDisplayTitle } from './TraceBadges'
export type TraceTreeFilter = 'all' | 'llm' | 'tool' | 'error'
const TRACE_TREE_FILTERS: readonly TraceTreeFilter[] = ['all', 'llm', 'tool', 'error']
type TreeRow = {
span: TraceSpan
depth: number
}
type TreeGroup = {
turnId: string
turnSpan: TraceSpan
rows: TreeRow[]
errorCount: number
}
export function TraceTree({
viewModel,
selectedId,
onSelect,
}: {
viewModel: TraceViewModel
selectedId: string | null
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
const [query, setQuery] = useState('')
const [filter, setFilter] = useState<TraceTreeFilter>('all')
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<string>>(new Set())
const scrollRef = useRef<HTMLDivElement>(null)
const groups = useMemo(
() => buildTreeGroups(viewModel, filter, query),
[viewModel, filter, query],
)
const navigableIds = useMemo(() => {
const ids: string[] = []
for (const group of groups) {
ids.push(group.turnId)
if (collapsedTurns.has(group.turnId)) continue
for (const row of group.rows) ids.push(row.span.id)
}
return ids
}, [groups, collapsedTurns])
useEffect(() => {
if (!selectedId) return
const container = scrollRef.current
if (!container) return
const row = container.querySelector<HTMLElement>(`[data-span-id="${CSS.escape(selectedId)}"]`)
row?.scrollIntoView({ block: 'nearest' })
}, [selectedId])
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
if (navigableIds.length === 0) return
event.preventDefault()
const currentIndex = selectedId ? navigableIds.indexOf(selectedId) : -1
const nextIndex = event.key === 'ArrowDown'
? Math.min(navigableIds.length - 1, currentIndex + 1)
: Math.max(0, currentIndex <= 0 ? 0 : currentIndex - 1)
const nextId = navigableIds[nextIndex]
if (nextId && nextId !== selectedId) onSelect(nextId)
}
const toggleTurn = (turnId: string) => {
setCollapsedTurns((previous) => {
const next = new Set(previous)
if (next.has(turnId)) next.delete(turnId)
else next.add(turnId)
return next
})
}
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface-container-low)]" data-testid="trace-tree">
<div className="shrink-0 border-b border-[var(--color-border)] px-4 pb-2.5 pt-3.5">
<SearchField
value={query}
onChange={setQuery}
label={t('trace.searchSpans')}
// Without this the clear button falls back to the field's own name,
// so both carry the same accessible label.
clearLabel={t('common.clearSearch')}
placeholder={t('trace.searchSpans')}
size="md"
/>
<SegmentedControl<TraceTreeFilter>
items={TRACE_TREE_FILTERS.map((value) => ({ value, label: filterLabel(value, t) }))}
value={filter}
onChange={setFilter}
label={t('trace.filter.aria')}
size="sm"
className="mt-2.5"
/>
</div>
<div
ref={scrollRef}
role="tree"
aria-label={t('trace.tree.aria')}
tabIndex={0}
onKeyDown={onKeyDown}
className="min-h-0 flex-1 overflow-y-auto pb-2 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
{groups.length > 0 ? (
groups.map((group) => (
<TurnGroup
key={group.turnId}
group={group}
collapsed={collapsedTurns.has(group.turnId)}
selectedId={selectedId}
onSelect={onSelect}
onToggle={() => toggleTurn(group.turnId)}
/>
))
) : (
<EmptyState description={t('trace.noMatchingSpans')} variant="plain" size="md" />
)}
</div>
</div>
)
}
function TurnGroup({
group,
collapsed,
selectedId,
onSelect,
onToggle,
}: {
group: TreeGroup
collapsed: boolean
selectedId: string | null
onSelect: (spanId: string) => void
onToggle: () => void
}) {
const t = useTranslation()
const turnSpan = group.turnSpan
const selected = selectedId === group.turnId
const turnNumber = (turnSpan.turnIndex ?? 0) + 1
const turnLabel = t('trace.turnLabel', { index: turnNumber })
const preview = turnPreview(turnSpan, t)
return (
<section>
<div
className={`sticky top-0 z-10 flex items-center gap-1.5 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] py-1.5 pl-2.5 pr-4 ${
selected ? 'shadow-[inset_3px_0_0_var(--color-brand)]' : ''
}`}
>
<IconButton
icon={collapsed
? <ChevronRight size={13} strokeWidth={2} />
: <ChevronDown size={13} strokeWidth={2} />}
label={t('trace.tree.toggleTurn')}
size="2xs"
tone="muted"
aria-expanded={!collapsed}
onClick={onToggle}
/>
<button
type="button"
onClick={() => onSelect(group.turnId)}
data-span-id={group.turnId}
className="flex min-w-0 flex-1 items-baseline gap-2 text-left"
>
<span className={`shrink-0 text-[13px] font-semibold ${
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
}`}>
{turnLabel}
</span>
{preview && preview !== turnLabel ? (
<span className="truncate text-[12.5px] text-[var(--color-text-tertiary)]">{preview}</span>
) : null}
</button>
{group.errorCount > 0 ? (
<span className="shrink-0 font-mono text-[11px] font-semibold text-[var(--color-error)]">
{group.errorCount}
</span>
) : null}
</div>
{!collapsed ? group.rows.map((row) => (
<TreeRowButton
key={row.span.id}
row={row}
selected={selectedId === row.span.id}
onSelect={() => onSelect(row.span.id)}
/>
)) : null}
</section>
)
}
function TreeRowButton({ row, selected, onSelect }: { row: TreeRow; selected: boolean; onSelect: () => void }) {
const t = useTranslation()
const span = row.span
const preview = rowPreview(span, t)
const duration = span.durationMs !== undefined ? formatDurationMs(span.durationMs) : null
const tokens = rowTokens(span)
return (
<button
type="button"
role="treeitem"
aria-selected={selected}
aria-level={row.depth + 1}
data-span-id={span.id}
onClick={onSelect}
className={`trace-row-cv relative flex h-[34px] w-full items-center gap-2 pr-4 text-left transition-colors ${
selected
? 'bg-[var(--color-surface-hover)]'
: 'hover:bg-[var(--color-surface-hover)]'
}`}
style={{ paddingLeft: `${16 + row.depth * 14}px` }}
>
{selected ? <span className="absolute inset-y-0 left-0 w-[3px] bg-[var(--color-brand)]" aria-hidden="true" /> : null}
<TypeIcon span={span} />
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className={`shrink-0 truncate text-[13.5px] font-semibold ${
selected ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)]'
}`}>
{spanDisplayTitle(span, t)}
</span>
{preview ? (
<span className="truncate text-[12.5px] text-[var(--color-text-tertiary)]">{preview}</span>
) : null}
{span.isSidechain ? (
<span className="shrink-0 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-1 text-[10px] text-[var(--color-text-tertiary)]">
{t('trace.sidechain')}
</span>
) : null}
</span>
{tokens ? (
<span className="shrink-0 font-mono text-[11.5px] text-[var(--color-text-tertiary)]">{tokens}</span>
) : null}
{duration ? (
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-tertiary)]">{duration}</span>
) : null}
<StatusGlyph status={span.status} />
</button>
)
}
function rowPreview(span: TraceSpan, t: ReturnType<typeof useTranslation>): string | null {
// A tool row's subtitle is its summarized input, which is what distinguishes
// one Bash call from the next; without it every tool row reads the same.
if (span.kind === 'message' || span.kind === 'event' || span.kind === 'tool') {
const preview = span.subtitle
if (preview && preview !== 'empty') return preview
// An assistant turn that only reasoned has no extractable text, which would
// otherwise leave the row as a bare "Assistant" label with nothing on it.
return span.kind === 'message' ? reasoningPreview(span.message?.content, t) : null
}
return null
}
const REASONING_PREVIEW_MAX = 120
/**
* What an assistant turn did when it produced no visible text. Readable
* reasoning is previewed; provider reasoning the harness stores encoded is
* named rather than dumped, since its payload is not text a reader can use.
*/
function reasoningPreview(content: unknown, t: ReturnType<typeof useTranslation>): string | null {
if (!Array.isArray(content)) return null
let redacted = false
for (const block of content) {
if (!block || typeof block !== 'object') continue
const record = block as { type?: unknown; thinking?: unknown }
if (record.type === 'redacted_thinking') {
redacted = true
continue
}
if (record.type !== 'thinking' || typeof record.thinking !== 'string') continue
const text = record.thinking.replace(/\s+/g, ' ').trim()
if (text.length === 0) continue
return text.length > REASONING_PREVIEW_MAX ? `${text.slice(0, REASONING_PREVIEW_MAX)}…` : text
}
return redacted ? t('trace.message.redactedReasoning') : null
}
/**
* Compact token counts for a model call, so cost reads off the row itself.
* Counts only `inputTokens`, matching the detail header's `formatUsageBrief`
* and the session overview — a row that added cached tokens here would
* contradict both the moment prompt caching is on.
*/
function rowTokens(span: TraceSpan): string | null {
if (span.kind !== 'llm') return null
const usage = span.call?.usage
if (!usage) return null
if (usage.inputTokens === 0 && usage.outputTokens === 0) return null
return `${formatTokenCount(usage.inputTokens)}↑ ${formatTokenCount(usage.outputTokens)}↓`
}
function turnPreview(turnSpan: TraceSpan, t: ReturnType<typeof useTranslation>): string {
return turnDisplayTitle(turnSpan.title, (turnSpan.turnIndex ?? 0) + 1, t)
}
function buildTreeGroups(viewModel: TraceViewModel, filter: TraceTreeFilter, query: string): TreeGroup[] {
const visibleIds = filterSpanIds(viewModel, filter, query)
const depthById = computeDepths(viewModel)
const groupsByTurn = new Map<string, TreeGroup>()
const groups: TreeGroup[] = []
for (const id of viewModel.orderedSpanIds) {
const span = viewModel.spansById.get(id)
if (!span) continue
if (span.kind === 'session') continue
if (span.kind === 'turn') {
const group: TreeGroup = { turnId: span.id, turnSpan: span, rows: [], errorCount: 0 }
groupsByTurn.set(span.id, group)
groups.push(group)
continue
}
if (span.kind === 'tool_result') continue
if (span.isLifecycleNoise === true) continue
if (!visibleIds.has(span.id)) continue
const turnId = `turn:${span.turnIndex ?? 0}`
const group = groupsByTurn.get(turnId)
if (!group) continue
// Depth relative to the turn header: session=0, turn=1, direct child=2.
const depth = Math.max(0, (depthById.get(span.id) ?? 2) - 2)
group.rows.push({ span, depth })
if (span.status === 'error') group.errorCount += 1
}
return groups.filter((group) => group.rows.length > 0 || (!query.trim() && filter === 'all'))
}
function filterSpanIds(viewModel: TraceViewModel, filter: TraceTreeFilter, query: string): Set<string> {
const normalizedQuery = query.trim().toLowerCase()
const matched = new Set<string>()
for (const span of viewModel.spans) {
const filterMatch =
filter === 'all' ||
(filter === 'llm' && span.kind === 'llm') ||
(filter === 'tool' && (span.kind === 'tool' || span.kind === 'tool_result')) ||
(filter === 'error' && span.status === 'error')
const queryMatch = !normalizedQuery || spanSearchText(span).includes(normalizedQuery)
if (filterMatch && queryMatch) {
includeWithAncestors(viewModel, span.id, matched)
}
}
return matched
}
function includeWithAncestors(viewModel: TraceViewModel, spanId: string, target: Set<string>) {
let current = viewModel.spansById.get(spanId)
while (current) {
target.add(current.id)
current = current.parentId ? viewModel.spansById.get(current.parentId) : undefined
}
}
function spanSearchText(span: TraceSpan): string {
return [
span.title,
span.subtitle,
span.kind,
span.status,
span.toolName,
span.toolUseId,
span.call?.model,
span.call?.provider?.name,
span.call?.request.url,
span.event?.phase,
span.event?.message,
span.event?.provider?.name,
previewTraceValue(span.raw, 500),
].filter(Boolean).join(' ').toLowerCase()
}
function computeDepths(viewModel: TraceViewModel): Map<string, number> {
const depths = new Map<string, number>()
const visit = (id: string, depth: number) => {
depths.set(id, depth)
const span = viewModel.spansById.get(id)
if (!span) return
for (const childId of span.childIds) visit(childId, depth + 1)
}
visit(viewModel.rootId, 0)
return depths
}
function filterLabel(filter: TraceTreeFilter, t: ReturnType<typeof useTranslation>): string {
switch (filter) {
case 'llm': return t('trace.filter.llm')
case 'tool': return t('trace.filter.tools')
case 'error': return t('trace.filter.errors')
default: return t('trace.filter.all')
}
}
@@ -1,59 +0,0 @@
import { useMemo } from 'react'
import { useTranslation } from '../../../i18n'
import type { MessageEntry } from '../../../types/session'
import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import type { NormalizedBlock, NormalizedMessage } from '../../../lib/trace/types'
import { normalizeContentBlock } from '../../../lib/trace/sse'
import { CodeViewer } from '../../chat/CodeViewer'
import { EmptyState } from '@/components/ui/EmptyState'
import { Section } from './Section'
import { MessageBlocks } from './MessageBlocks'
export function MessageDetail({ span }: { span: TraceSpan }) {
const t = useTranslation()
const message = span.message
const normalized = useMemo(
() => message ? normalizeMessageEntry(message) : null,
[message],
)
if (!message || !normalized) return null
return (
<div data-testid="trace-message-detail">
<Section sectionKey="message.content" title={t('trace.section.content')} defaultOpen>
{normalized.content.length > 0 ? (
<MessageBlocks message={normalized} />
) : (
<EmptyState description={t('trace.noData')} variant="dashed" size="sm" />
)}
</Section>
<Section sectionKey="message.raw" title={t('trace.section.raw')}>
<CodeViewer code={formatTraceJson(message.content)} language="json" maxLines={48} showLineNumbers />
</Section>
</div>
)
}
function normalizeMessageEntry(message: MessageEntry): NormalizedMessage {
const role: NormalizedMessage['role'] =
message.type === 'assistant' || message.type === 'tool_use'
? 'assistant'
: message.type === 'system'
? 'system'
: message.type === 'tool_result'
? 'tool'
: 'user'
const content = message.content
if (typeof content === 'string') {
return { role, content: [{ type: 'text', text: content }] }
}
if (Array.isArray(content)) {
const blocks = content
.map((block) => normalizeContentBlock(block))
.filter((block): block is NormalizedBlock => block !== null)
return { role, content: blocks }
}
return { role, content: [] }
}
@@ -1,260 +0,0 @@
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from '../../../i18n'
import type { TraceSpan, TraceViewModel } from '../../../lib/traceViewModel'
import { formatDurationMs, formatTokenCount } from '../../../lib/trace/formatters'
import { fetchTraceCallDetail } from '../../../lib/trace/callCache'
import { parseTraceRequestBody } from '../../../lib/trace/requestParse'
import { StatusGlyph, TypeIcon, spanDisplayTitle } from '../TraceBadges'
import { Section } from './Section'
/**
* The model-visible request header as the session opened, read from the first
* model call so it does not have to be hunted down inside an individual one.
* Only that call is fetched, because session-level snapshots truncate request
* bodies.
*
* It is the opening header, not a session-wide invariant: late tool
* registration and a mid-session model change both rewrite the system prompt
* and tool catalog for later requests. A specific request's own header stays on
* that request's detail.
*/
function SessionRequestHeader({
viewModel,
sessionId,
revisionKey,
}: {
viewModel: TraceViewModel
sessionId?: string
revisionKey?: string
}) {
const t = useTranslation()
const [header, setHeader] = useState<{
system?: string
tools: Array<{ name: string; description?: string }>
} | null>(null)
const firstCallId = useMemo(() => {
for (const candidate of viewModel.spans) {
if (candidate.kind === 'llm' && candidate.call?.id) return candidate.call.id
}
return null
}, [viewModel])
useEffect(() => {
if (!sessionId || !firstCallId) {
// Without this, switching to a session that has no call keeps showing the
// previous session's system prompt.
setHeader(null)
return
}
let cancelled = false
void fetchTraceCallDetail(sessionId, firstCallId, revisionKey).then((call) => {
if (cancelled) return
if (!call) return
const semantic = call.request.semantic
const requestBody = semantic ? JSON.stringify(semantic.request) : call.request.body.preview
const parsed = requestBody
? parseTraceRequestBody(requestBody, semantic ? 'anthropic' : call.source)
: null
if (!parsed) return
setHeader({
...(parsed.system !== undefined ? { system: parsed.system } : {}),
tools: parsed.tools.map((tool) => ({
name: tool.name,
...(tool.description ? { description: tool.description } : {}),
})),
})
})
return () => { cancelled = true }
}, [sessionId, firstCallId, revisionKey])
if (!header) return null
const hasSystem = Boolean(header.system)
if (!hasSystem && header.tools.length === 0) return null
return (
<div className="mt-5 flex flex-col gap-1">
{header.system ? (
<Section
sectionKey="overview.systemPrompt"
title={t('trace.section.systemPrompt')}
badge={t('trace.detail.chars', { count: header.system.length })}
>
<pre className="max-h-[360px] overflow-y-auto whitespace-pre-wrap break-words text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{header.system}
</pre>
</Section>
) : null}
{header.tools.length > 0 ? (
<Section sectionKey="overview.tools" title={t('trace.section.tools')} badge={header.tools.length}>
<div className="flex flex-col gap-1">
{header.tools.map((tool) => (
<div key={tool.name} className="flex min-w-0 items-baseline gap-2">
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-primary)]">{tool.name}</span>
{tool.description ? (
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]">
{tool.description}
</span>
) : null}
</div>
))}
</div>
</Section>
) : null}
</div>
)
}
type OverviewStats = {
llmCalls: number
toolCalls: number
errors: number
wallDurationMs?: number
modelDurationMs?: number
toolDurationMs?: number
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheCreationTokens: number
models: string[]
}
export function SessionOverview({
span,
viewModel,
onSelect,
sessionId,
revisionKey,
}: {
span: TraceSpan
viewModel: TraceViewModel
onSelect: (spanId: string) => void
sessionId?: string
revisionKey?: string
}) {
const t = useTranslation()
const stats = useMemo(() => computeStats(span, viewModel), [span, viewModel])
const children = span.childIds
.map((id) => viewModel.spansById.get(id))
.filter((child): child is TraceSpan => !!child && child.isLifecycleNoise !== true)
return (
<div className="px-6 py-5" data-testid="trace-overview">
<div className="grid grid-cols-2 gap-x-6 gap-y-4 md:grid-cols-3">
<Stat label={t('trace.llmCalls')} value={String(stats.llmCalls)} />
<Stat label={t('trace.toolCalls')} value={String(stats.toolCalls)} />
<Stat label={t('trace.errors')} value={String(stats.errors)} tone={stats.errors > 0 ? 'danger' : 'default'} />
<Stat label={t('trace.wallTime')} value={formatDurationMs(stats.wallDurationMs)} />
<Stat label={t('trace.modelTime')} value={formatDurationMs(stats.modelDurationMs)} />
<Stat label={t('trace.toolTime')} value={formatDurationMs(stats.toolDurationMs)} />
<Stat
label={t('trace.inputOutputTokens')}
value={`${formatTokenCount(stats.inputTokens)} → ${formatTokenCount(stats.outputTokens)}`}
/>
{stats.cacheReadTokens > 0 || stats.cacheCreationTokens > 0 ? (
<Stat
label={t('trace.cacheTokens')}
value={`${formatTokenCount(stats.cacheReadTokens)} → ${formatTokenCount(stats.cacheCreationTokens)}`}
/>
) : null}
<Stat label={t('trace.models')} value={stats.models.length > 0 ? stats.models.join(', ') : '--'} />
</div>
<SessionRequestHeader viewModel={viewModel} sessionId={sessionId} revisionKey={revisionKey} />
{children.length > 0 ? (
<div className="mt-6">
<div className="mb-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">
{t('trace.childSpans')}
</div>
<div className="divide-y divide-[var(--color-border)]">
{children.map((child) => (
<button
key={child.id}
type="button"
onClick={() => onSelect(child.id)}
className="flex h-[34px] w-full items-center gap-2 text-left transition-colors hover:bg-[var(--color-surface-hover)]"
>
<TypeIcon span={child} />
<span className="min-w-0 flex-1 truncate text-[13.5px] font-semibold text-[var(--color-text-secondary)]">
{spanDisplayTitle(child, t)}
</span>
{child.durationMs !== undefined ? (
<span className="shrink-0 font-mono text-[12px] text-[var(--color-text-tertiary)]">
{formatDurationMs(child.durationMs)}
</span>
) : null}
<StatusGlyph status={child.status} />
</button>
))}
</div>
</div>
) : null}
</div>
)
}
function Stat({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
return (
<div className="min-w-0">
<div className="text-[11px] font-medium uppercase tracking-[0.1em] text-[var(--color-text-tertiary)]">{label}</div>
<div className={`mt-1 truncate font-mono text-[15px] font-semibold ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>
{value}
</div>
</div>
)
}
function computeStats(span: TraceSpan, viewModel: TraceViewModel): OverviewStats {
const scoped = span.kind === 'session'
? viewModel.spans.filter((item) => item.id !== viewModel.rootId)
: collectSubtree(span, viewModel)
const stats: OverviewStats = {
llmCalls: 0,
toolCalls: 0,
errors: 0,
inputTokens: 0,
outputTokens: 0,
cacheReadTokens: 0,
cacheCreationTokens: 0,
models: [],
}
const models = new Set<string>()
let modelDurationMs = 0
let toolDurationMs = 0
for (const item of scoped) {
if (item.kind === 'llm') {
stats.llmCalls += 1
if (item.call?.model) models.add(item.call.model)
if (item.durationMs !== undefined) modelDurationMs += item.durationMs
if (item.tokenUsage) {
stats.inputTokens += item.tokenUsage.inputTokens
stats.outputTokens += item.tokenUsage.outputTokens
stats.cacheReadTokens += item.tokenUsage.cacheReadInputTokens ?? 0
stats.cacheCreationTokens += item.tokenUsage.cacheCreationInputTokens ?? 0
}
}
if (item.kind === 'tool') {
stats.toolCalls += 1
if (item.durationMs !== undefined) toolDurationMs += item.durationMs
}
if (item.status === 'error') stats.errors += 1
}
stats.models = [...models]
if (span.durationMs !== undefined && span.durationMs > 0) stats.wallDurationMs = span.durationMs
if (modelDurationMs > 0) stats.modelDurationMs = modelDurationMs
if (toolDurationMs > 0) stats.toolDurationMs = toolDurationMs
return stats
}
function collectSubtree(span: TraceSpan, viewModel: TraceViewModel): TraceSpan[] {
const result: TraceSpan[] = []
const visit = (id: string) => {
const current = viewModel.spansById.get(id)
if (!current) return
if (current.id !== span.id) result.push(current)
for (const childId of current.childIds) visit(childId)
}
visit(span.id)
return result
}
@@ -1,105 +0,0 @@
import { useTranslation } from '../../../i18n'
import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import { formatClockTime, formatDurationMs } from '../../../lib/trace/formatters'
import { CodeViewer } from '../../chat/CodeViewer'
import { EmptyState } from '@/components/ui/EmptyState'
import { Section } from './Section'
export function ToolDetail({ span }: { span: TraceSpan }) {
const t = useTranslation()
const outputs = collectOutputs(span)
const pending = span.status === 'pending'
return (
<div data-testid="trace-tool-detail">
{span.input !== undefined ? (
<Section sectionKey="tool.input" title={t('trace.section.input')} defaultOpen>
<CodeViewer code={formatTraceJson(span.input)} language="json" maxLines={32} showLineNumbers />
</Section>
) : null}
<Section sectionKey="tool.result" title={t('trace.section.result')} defaultOpen>
{pending ? (
<EmptyState description={t('trace.waitingForResult')} variant="dashed" size="sm" />
) : outputs.length > 0 ? (
<div className="flex flex-col gap-2">
{outputs.map((output, index) => <OutputView key={index} value={output} />)}
</div>
) : (
<EmptyState description={t('trace.noData')} variant="dashed" size="sm" />
)}
</Section>
<Section sectionKey="tool.meta" title={t('trace.section.meta')}>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-[12.5px]">
{span.toolUseId ? (
<MetaRow label={t('trace.detail.toolUseId')} value={span.toolUseId} />
) : null}
<MetaRow
label={t('trace.status')}
value={span.status === 'error' ? t('trace.status.error') : span.status === 'pending' ? t('trace.status.pending') : t('trace.status.ok')}
/>
<MetaRow label={t('trace.started')} value={formatClockTime(span.timestamp)} />
{span.completedAt ? (
<MetaRow label={t('trace.completed')} value={formatClockTime(span.completedAt)} />
) : null}
{span.durationMs !== undefined ? (
<MetaRow
label={span.status === 'pending' ? t('trace.elapsed') : t('trace.duration')}
value={formatDurationMs(span.durationMs)}
/>
) : null}
</dl>
</Section>
</div>
)
}
function MetaRow({ label, value }: { label: string; value: string }) {
return (
<>
<dt className="text-[var(--color-text-tertiary)]">{label}</dt>
<dd className="min-w-0 truncate font-mono text-[var(--color-text-secondary)]">{value}</dd>
</>
)
}
function OutputView({ value }: { value: unknown }) {
const text = extractPlainText(value)
if (text !== null) {
if (!text.trim()) return null
return (
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] px-3 py-2 font-mono text-[12px] leading-[1.7] text-[var(--color-text-secondary)]">
{text}
</pre>
)
}
return <CodeViewer code={formatTraceJson(value)} language="json" maxLines={32} showLineNumbers />
}
function collectOutputs(span: TraceSpan): unknown[] {
if (span.output === undefined) return []
if (Array.isArray(span.output)) return span.output
return [span.output]
}
function extractPlainText(content: unknown): string | null {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
const parts: string[] = []
for (const item of content) {
if (typeof item === 'string') {
parts.push(item)
continue
}
if (item && typeof item === 'object' && typeof (item as { text?: unknown }).text === 'string') {
parts.push((item as { text: string }).text)
continue
}
return null
}
return parts.join('\n')
}
return null
}
@@ -0,0 +1,92 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TrajectoryRow } from '../../types/trajectory'
vi.mock('../../api/trajectory', () => ({
trajectoryApi: {
getRow: vi.fn(async () => ({ rowId: 'a:1', truncated: false, entries: [] })),
getSnapshot: vi.fn(),
getTraceCallsNear: vi.fn(async () => ({ captured: false, calls: [] })),
},
}))
import { TrajectoryDetailPanel } from './TrajectoryDetailPanel'
import { clearTrajectoryDetailCache } from './useTrajectoryDetail'
import { useSettingsStore } from '../../stores/settingsStore'
const ROW: TrajectoryRow = { id: 'a:1', kind: 'assistant', turn: 2, ts: '2026-10-04T10:00:01.000Z', preview: 'Hello', loc: [[0, 10]] }
function renderPanel(props: Partial<Parameters<typeof TrajectoryDetailPanel>[0]> = {}) {
const handlers = { onWidthChange: vi.fn(), onClose: vi.fn() }
render(
<TrajectoryDetailPanel
sessionId="s1"
row={ROW}
width={600}
maxWidth={900}
mode="side"
allowRawRequest
running={false}
onLocateInChat={null}
onOpenAgent={vi.fn()}
onSelectRow={vi.fn()}
{...handlers}
{...props}
/>,
)
return handlers
}
describe('TrajectoryDetailPanel', () => {
beforeEach(() => {
clearTrajectoryDetailCache()
useSettingsStore.setState({ locale: 'en' })
})
it('never grows past the room the ledger leaves it', () => {
// Regression: a fixed 600px panel squeezed the ledger to 19px with the workspace open.
renderPanel({ width: 720, maxWidth: 400 })
expect(screen.getByTestId('trajectory-detail')).toHaveStyle({ width: '400px' })
})
it('clamps resizing to the available room', () => {
const { onWidthChange } = renderPanel({ width: 400, maxWidth: 420 })
fireEvent.keyDown(screen.getByRole('separator', { name: 'Resize details panel' }), { key: 'ArrowLeft' })
expect(onWidthChange).toHaveBeenLastCalledWith(416)
fireEvent.keyDown(screen.getByRole('separator', { name: 'Resize details panel' }), { key: 'ArrowLeft' })
fireEvent.doubleClick(screen.getByRole('separator', { name: 'Resize details panel' }))
expect(onWidthChange).toHaveBeenLastCalledWith(420)
})
it('becomes a full-width drawer with a back button in a narrow column', () => {
const { onClose } = renderPanel({ mode: 'drawer' })
const panel = screen.getByTestId('trajectory-detail')
expect(panel).toHaveAttribute('data-mode', 'drawer')
expect(panel).toHaveStyle({ width: '100%' })
expect(screen.queryByRole('separator')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Back to list' }))
expect(onClose).toHaveBeenCalled()
})
it('offers the raw request tab on main-session responses, and only there', () => {
renderPanel()
expect(screen.getByRole('tab', { name: 'Raw request' })).toBeInTheDocument()
})
it('lets the raw JSON fill the panel instead of a capped inner box', async () => {
renderPanel()
fireEvent.click(screen.getByRole('tab', { name: 'Raw' }))
const area = await waitFor(() => {
const element = screen.getByTestId('trajectory-detail').querySelector('.code-viewer-area')
expect(element).toBeTruthy()
return element!
})
expect(area.className).not.toContain('max-h-')
})
it('hides the raw request tab for a subagent response', () => {
renderPanel({ allowRawRequest: false })
expect(screen.queryByRole('tab', { name: 'Raw request' })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,439 @@
import { useEffect, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent } from 'react'
import { ArrowLeft, Bot, CircleX, LocateFixed, X } from 'lucide-react'
import { useTranslation } from '../../i18n'
import type { TranslationKey } from '../../i18n/locales/en'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { EmptyState } from '@/components/ui/EmptyState'
import { IconButton } from '@/components/ui/IconButton'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import { Spinner } from '@/components/ui/Spinner'
import { formatClockTime, formatDurationMs, formatTokenCount } from '../../lib/trace/formatters'
import { attachmentText, extractDetailContent } from '../../lib/trajectory/detailContent'
import { hasUsage, rowDurationMs, toolHasResult } from '../../lib/trajectory/viewModel'
import { formatBytes } from '../../lib/formatBytes'
import { cx } from '@/lib/cx'
import { TRAJECTORY_DETAIL_WIDTH_DEFAULT, TRAJECTORY_DETAIL_WIDTH_MIN, clampDetailWidth } from '../../stores/trajectoryViewStore'
import type { TrajectoryRow } from '../../types/trajectory'
import { KIND_LABEL_KEYS, KIND_TONES, KIND_VARIANTS, agentLabelFromInput, contextLabel, systemRowTitle, turnLabel } from './trajectoryLabels'
import { useRowDetail } from './useTrajectoryDetail'
import { DetailSection, JsonBlock, MarkdownText, MetaList, PlainText, type MetaItem } from './detail/DetailBlocks'
import { SnapshotDetail, snapshotTabs, type SnapshotTab } from './detail/SnapshotDetail'
import { RawRequestTab } from './detail/RawRequestTab'
type RowTab = 'overview' | 'preview' | 'input' | 'output' | 'raw' | 'request'
type DetailTab = RowTab | SnapshotTab
const TAB_LABEL_KEYS: Record<DetailTab, TranslationKey> = {
overview: 'trajectory.tab.overview',
preview: 'trajectory.tab.preview',
input: 'trajectory.tab.input',
output: 'trajectory.tab.output',
raw: 'trajectory.tab.raw',
request: 'trajectory.tab.request',
prompt: 'trajectory.tab.prompt',
tools: 'trajectory.tab.tools',
context: 'trajectory.tab.context',
diff: 'trajectory.tab.diff',
}
function tabsFor(row: TrajectoryRow, allowRawRequest: boolean): DetailTab[] {
if (row.snapshot) return snapshotTabs(row.snapshot)
switch (row.kind) {
case 'tool': return ['overview', 'input', 'output', 'raw']
case 'assistant': return allowRawRequest ? ['overview', 'preview', 'raw', 'request'] : ['overview', 'preview', 'raw']
case 'compact': return ['overview', 'raw']
default: return ['overview', 'preview', 'raw']
}
}
type Props = {
sessionId: string
agentId?: string
row: TrajectoryRow
width: number
/** Widest the panel may get without squeezing the ledger below its minimum. */
maxWidth: number
/** `drawer` overlays the ledger when the column is too narrow to share. */
mode: 'side' | 'drawer'
/** The raw-request tab (captures exist only for the main session's calls). */
allowRawRequest: boolean
running: boolean
onWidthChange: (width: number) => void
onClose: () => void
onLocateInChat: ((row: TrajectoryRow) => void) | null
onOpenAgent: (agentId: string, label: string) => void
onSelectRow: (rowId: string) => void
}
export function TrajectoryDetailPanel({
sessionId,
agentId,
row,
width,
maxWidth,
mode,
allowRawRequest,
running,
onWidthChange,
onClose,
onLocateInChat,
onOpenAgent,
onSelectRow,
}: Props) {
const t = useTranslation()
const tabs = tabsFor(row, allowRawRequest)
const [preferred, setPreferred] = useState<DetailTab>('overview')
const tab: DetailTab = tabs.includes(preferred) ? preferred : tabs[0]!
const kindLabel = row.snapshot ? systemRowTitle(row, t) : t(KIND_LABEL_KEYS[row.kind])
const context = row.kind === 'context' ? contextLabel(row, t) : null
const subtitle = [turnLabel(row.turn, t), context, row.kind === 'tool' ? row.toolName : null].filter(Boolean).join(' · ')
const drawer = mode === 'drawer'
return (
<aside
className={cx(
'flex min-h-0 flex-col border-l border-[var(--color-border)] bg-[var(--color-surface)]',
drawer ? 'absolute inset-y-0 right-0 z-[var(--z-raised)] shadow-[var(--shadow-overlay)]' : 'relative shrink-0',
)}
style={{ width: drawer ? '100%' : Math.min(width, maxWidth) }}
aria-label={t('trajectory.detail.label')}
data-testid="trajectory-detail"
data-mode={mode}
>
{!drawer && <ResizeHandle width={Math.min(width, maxWidth)} maxWidth={maxWidth} onWidthChange={onWidthChange} />}
<header className="flex shrink-0 items-center gap-2 px-4 pt-3">
{drawer && (
<IconButton icon={<ArrowLeft size={14} />} label={t('trajectory.detail.back')} size="sm" onClick={onClose} />
)}
<Badge tone={row.isError ? 'danger' : KIND_TONES[row.kind]} variant={KIND_VARIANTS[row.kind]} size="xs" pill={false}>{t(KIND_LABEL_KEYS[row.kind])}</Badge>
<span className="min-w-0 truncate text-[12.5px] text-[var(--color-text-secondary)]">
{row.snapshot ? kindLabel : subtitle}
{row.snapshot && subtitle ? ` · ${subtitle}` : ''}
</span>
{!drawer && <IconButton icon={<X size={14} />} label={t('common.close')} size="sm" className="ml-auto" onClick={onClose} />}
</header>
<div className="shrink-0 px-4 pt-2">
<SegmentedControl
as="tablist"
appearance="underline"
size="sm"
layout="auto"
label={t('trajectory.detail.tabs')}
value={tab}
onChange={setPreferred}
items={tabs.map((value) => ({ value, label: t(TAB_LABEL_KEYS[value]) }))}
/>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3" role="tabpanel">
{row.snapshot ? (
<SnapshotDetail sessionId={sessionId} snapshot={row.snapshot} tab={tab as SnapshotTab} />
) : tab === 'request' ? (
<RawRequestTab sessionId={sessionId} row={row} />
) : (
<RowDetail
sessionId={sessionId}
agentId={agentId}
row={row}
tab={tab as RowTab}
running={running}
onLocateInChat={onLocateInChat}
onOpenAgent={onOpenAgent}
onSelectRow={onSelectRow}
/>
)}
</div>
</aside>
)
}
function RowDetail({
sessionId,
agentId,
row,
tab,
running,
onLocateInChat,
onOpenAgent,
onSelectRow,
}: {
sessionId: string
agentId?: string
row: TrajectoryRow
tab: RowTab
running: boolean
onLocateInChat: ((row: TrajectoryRow) => void) | null
onOpenAgent: (agentId: string, label: string) => void
onSelectRow: (rowId: string) => void
}) {
const t = useTranslation()
const detail = useRowDetail(sessionId, agentId, row)
const content = useMemo(
() => (detail.value ? extractDetailContent(row, detail.value.entries) : null),
[detail.value, row],
)
if (tab === 'overview') {
return (
<Overview
row={row}
content={content}
running={running}
loading={detail.status === 'loading'}
onLocateInChat={onLocateInChat}
onOpenAgent={onOpenAgent}
onSelectRow={onSelectRow}
/>
)
}
if (detail.status === 'error') {
return <EmptyState description={t('trajectory.detail.loadFailed', { error: detail.error ?? '' })} variant="dashed" size="sm" />
}
if (!detail.value || !content) return <div className="flex justify-center py-8"><Spinner size={16} /></div>
const truncatedNotice = detail.value.truncated ? (
<p className="text-[12px] text-[var(--color-text-tertiary)]">{t('trajectory.detail.truncated')}</p>
) : null
if (tab === 'raw') {
return (
<div className="flex flex-col gap-2">
{truncatedNotice}
<JsonBlock value={detail.value.entries.length === 1 ? detail.value.entries[0] : detail.value.entries} />
</div>
)
}
if (tab === 'input') {
return content.toolInput === undefined
? <EmptyState description={t('trajectory.detail.notLoaded')} variant="dashed" size="sm" />
: <JsonBlock value={content.toolInput} />
}
if (tab === 'output') {
if (content.toolResult === undefined) {
const reason = row.resultOmittedBytes
? t('trajectory.tool.resultTooLarge', { size: formatBytes(row.resultOmittedBytes) })
: t(toolHasResult(row) ? 'trajectory.detail.notLoaded' : running ? 'trajectory.tool.running' : 'trajectory.tool.noResult')
return <EmptyState description={reason} variant="dashed" size="sm" />
}
return (
<div className="flex flex-col gap-4">
{truncatedNotice}
{content.imageCount > 0 && (
<p className="text-[12px] text-[var(--color-text-tertiary)]">{t('trajectory.detail.images', { count: content.imageCount })}</p>
)}
<DetailSection title={t('trajectory.detail.resultText')}>
<PlainText text={content.toolResult || t('trajectory.tool.emptyResult')} />
</DetailSection>
{content.toolResultStructured !== undefined && (
<DetailSection title={t('trajectory.detail.resultStructured')}>
<JsonBlock value={content.toolResultStructured} maxLines={120} />
</DetailSection>
)}
</div>
)
}
// preview
const text = content.text || (content.attachment !== undefined ? attachmentText(content.attachment) : '')
return (
<div className="flex flex-col gap-4">
{truncatedNotice}
{content.thinking.length > 0 && (
<details className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2" open={!text}>
<summary className="cursor-pointer text-[12px] text-[var(--color-text-tertiary)]">{t('trajectory.detail.thinking')}</summary>
<div className="mt-2 text-[13px]"><MarkdownText text={content.thinking.join('\n\n')} /></div>
</details>
)}
{text ? <div className="text-[13px]"><MarkdownText text={text} /></div> : null}
{content.toolUses.length > 0 && (
<DetailSection title={t('trajectory.table.toolCount', { count: content.toolUses.length })}>
<div className="flex flex-wrap gap-1.5">
{content.toolUses.map((use) => (
<button
key={use.id}
type="button"
onClick={() => onSelectRow(`t:${use.id}`)}
className="rounded-full border border-[var(--color-border)] px-2.5 py-0.5 font-mono text-[11.5px] text-[var(--color-text-secondary)] hover:border-[var(--color-outline)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{use.name}
</button>
))}
</div>
</DetailSection>
)}
{!text && !content.thinking.length && !content.toolUses.length && (
<EmptyState description={t('trajectory.detail.empty')} variant="dashed" size="sm" />
)}
</div>
)
}
function Overview({
row,
content,
running,
loading,
onLocateInChat,
onOpenAgent,
onSelectRow,
}: {
row: TrajectoryRow
content: ReturnType<typeof extractDetailContent> | null
running: boolean
loading: boolean
onLocateInChat: ((row: TrajectoryRow) => void) | null
onOpenAgent: (agentId: string, label: string) => void
onSelectRow: (rowId: string) => void
}) {
const t = useTranslation()
const duration = rowDurationMs(row)
const items: MetaItem[] = [{ label: t('trajectory.detail.time'), value: formatClockTime(row.ts) }]
if (row.kind === 'context' && (row.attachmentType || row.label)) {
items.push({ label: t('trajectory.detail.source'), value: <span className="font-mono text-[12px]">{row.attachmentType ?? row.label}</span> })
}
if (row.kind === 'tool') {
items.push({
label: t('trajectory.detail.status'),
value: row.isError
? <span className="inline-flex items-center gap-1 text-[var(--color-error)]"><CircleX size={13} aria-hidden />{t('trajectory.status.error')}</span>
: toolHasResult(row)
? t('trajectory.status.done')
: t(running ? 'trajectory.tool.running' : 'trajectory.tool.noResult'),
})
if (row.resultOmittedBytes) {
items.push({ label: t('trajectory.tab.output'), value: t('trajectory.tool.resultTooLarge', { size: formatBytes(row.resultOmittedBytes) }) })
}
}
if (row.kind === 'assistant') {
if (row.model) items.push({ label: t('trajectory.detail.model'), value: <span className="font-mono text-[12px]">{row.model}</span> })
if (row.stopReason) items.push({ label: t('trajectory.detail.stopReason'), value: <span className="font-mono text-[12px]">{row.stopReason}</span> })
if (hasUsage(row) && row.usage) {
items.push({
label: t('trajectory.detail.tokens'),
value: t('trajectory.detail.tokenBreakdown', {
input: formatTokenCount(row.usage.input),
cacheRead: formatTokenCount(row.usage.cacheRead),
cacheWrite: formatTokenCount(row.usage.cacheWrite),
output: formatTokenCount(row.usage.output),
}),
})
}
}
if (duration !== null) {
items.push({
label: t('trajectory.detail.duration'),
value: row.kind === 'assistant' ? t('trajectory.detail.approx', { value: formatDurationMs(duration) }) : formatDurationMs(duration),
})
}
if (row.parentId) {
items.push({
label: t('trajectory.detail.issuedBy'),
value: (
<button type="button" className="rounded-[var(--radius-sm)] text-[var(--color-brand)] hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]" onClick={() => onSelectRow(row.parentId!)}>
{t('trajectory.kind.assistant')}
</button>
),
})
}
const preview = row.kind === 'tool'
? null
: content
? (content.text || (content.attachment !== undefined ? attachmentText(content.attachment) : '')).slice(0, 4000)
: row.preview
return (
<div className="flex flex-col gap-4">
<MetaList items={items} />
{(onLocateInChat || row.agentId) && (
<div className="flex flex-wrap gap-2">
{onLocateInChat && (row.kind === 'user' || row.kind === 'assistant' || row.kind === 'tool') && (
<Button size="sm" variant="secondary" icon={<LocateFixed size={13} />} onClick={() => onLocateInChat(row)}>
{t('trajectory.detail.locateInChat')}
</Button>
)}
{row.agentId && (
<Button size="sm" variant="secondary" icon={<Bot size={13} />} onClick={() => onOpenAgent(row.agentId!, agentLabelFromInput(row.inputPreview, row.agentId!))}>
{t('trajectory.subagent.view')}
</Button>
)}
</div>
)}
{row.kind === 'tool' ? (
<>
<DetailSection title={t('trajectory.tab.input')}>
{content?.toolInput !== undefined ? <JsonBlock value={content.toolInput} maxLines={24} /> : <PlainText text={row.inputPreview ?? ''} />}
</DetailSection>
{(row.resultPreview || content?.toolResult !== undefined) && (
<DetailSection title={t('trajectory.tab.output')}>
<PlainText text={(content?.toolResult ?? row.resultPreview ?? '').slice(0, 4000)} />
</DetailSection>
)}
</>
) : preview ? (
<DetailSection title={t('trajectory.tab.preview')}>
<div className="text-[13px]"><MarkdownText text={preview} /></div>
</DetailSection>
) : loading ? (
<div className="flex justify-center py-4"><Spinner size={14} /></div>
) : null}
</div>
)
}
const KEYBOARD_STEP = 16
function ResizeHandle({ width, maxWidth, onWidthChange }: { width: number; maxWidth: number; onWidthChange: (width: number) => void }) {
const t = useTranslation()
const drag = useRef<{ startX: number; startWidth: number } | null>(null)
const [dragging, setDragging] = useState(false)
const [liveWidth, setLiveWidth] = useState<number | null>(null)
// The stored preference is clamped to the absolute range; the layout's room clamps it further.
const clamp = (value: number) => Math.min(clampDetailWidth(value), Math.max(TRAJECTORY_DETAIL_WIDTH_MIN, maxWidth))
useEffect(() => {
if (!dragging) setLiveWidth(null)
}, [dragging])
const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
event.preventDefault()
event.currentTarget.setPointerCapture?.(event.pointerId)
drag.current = { startX: event.clientX, startWidth: width }
setDragging(true)
}
const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
if (!drag.current) return
// The panel sits on the right, so dragging left widens it.
const next = clamp(drag.current.startWidth + drag.current.startX - event.clientX)
setLiveWidth(next)
onWidthChange(next)
}
const onPointerUp = (event: PointerEvent<HTMLDivElement>) => {
event.currentTarget.releasePointerCapture?.(event.pointerId)
drag.current = null
setDragging(false)
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'ArrowLeft') onWidthChange(clamp(width + KEYBOARD_STEP))
else if (event.key === 'ArrowRight') onWidthChange(clamp(width - KEYBOARD_STEP))
else return
event.preventDefault()
}
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={t('trajectory.detail.resize')}
aria-valuenow={liveWidth ?? width}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onDoubleClick={() => onWidthChange(clamp(TRAJECTORY_DETAIL_WIDTH_DEFAULT))}
onKeyDown={onKeyDown}
className="absolute inset-y-0 -left-1 z-[var(--z-raised)] w-2 cursor-col-resize focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
data-testid="trajectory-detail-resize"
/>
)
}
@@ -0,0 +1,81 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { ToolCallBlock } from '../chat/ToolCallBlock'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
import { TrajectoryLinkContext } from './TrajectoryLinkContext'
function renderTool(toolUseId: string | undefined, linked: boolean) {
const block = <ToolCallBlock toolName="Bash" input={{ command: 'ls' }} result={{ content: 'a.txt', isError: false }} toolUseId={toolUseId} />
return render(linked ? <TrajectoryLinkContext.Provider value={{ sessionId: 's1' }}>{block}</TrajectoryLinkContext.Provider> : block)
}
describe('view in trajectory', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTrajectoryViewStore.setState({ modes: {}, opened: {}, nav: null })
})
it('switches the session to its trajectory and requests that tool row', () => {
renderTool('toolu_1', true)
fireEvent.click(screen.getByRole('button', { name: 'View in trajectory' }))
const state = useTrajectoryViewStore.getState()
expect(state.modes.s1).toBe('trajectory')
expect(state.opened.s1).toBe(true)
expect(state.nav).toMatchObject({ to: 'trajectory', sessionId: 's1', rowId: 't:toolu_1' })
})
it('does not toggle the tool card when the action is clicked', () => {
renderTool('toolu_1', true)
const header = screen.getByRole('button', { name: /Bash/ })
const before = header.getAttribute('aria-expanded')
fireEvent.click(screen.getByRole('button', { name: 'View in trajectory' }))
expect(header.getAttribute('aria-expanded')).toBe(before)
})
it.each(['card', 'row'] as const)('sits in the %s header beside the disclosure, left of the duration and chevron', (chrome) => {
// Regression: the action was laid absolutely over the header's right edge,
// clipping "1.5s" and swallowing clicks meant for the chevron.
const { container } = render(
<TrajectoryLinkContext.Provider value={{ sessionId: 's1' }}>
<ToolCallBlock chrome={chrome} toolName="Bash" input={{ command: 'ls' }} result={{ content: 'a.txt', isError: false }} durationMs={1500} toolUseId="toolu_1" />
</TrajectoryLinkContext.Provider>,
)
const disclosure = container.querySelector<HTMLButtonElement>('[data-chat-disclosure="true"]')!
const action = screen.getByRole('button', { name: 'View in trajectory' })
const duration = screen.getByText('1.5s')
// Siblings in one header row: never an interactive element inside another.
expect(disclosure.contains(action)).toBe(false)
expect(action.closest('button')).toBe(action)
expect(action.parentElement).toBe(disclosure.parentElement)
expect(duration.parentElement).toBe(disclosure.parentElement)
// In flow, ahead of the duration — not positioned over it.
expect(action.compareDocumentPosition(duration) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
expect(action.className).not.toMatch(/\babsolute\b/)
// Hidden until the header is hovered, but keyboard focus still reveals it.
expect(action.className).toContain('opacity-0')
expect(action.className).toContain('focus-visible:opacity-100')
// The disclosure keeps working on its own.
expect(disclosure).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(disclosure)
expect(disclosure).toHaveAttribute('aria-expanded', 'true')
})
it('reserves no slot when there is no trajectory to link to', () => {
const { container } = renderTool('toolu_1', false)
const disclosure = container.querySelector('[data-chat-disclosure="true"]')!
expect(disclosure.parentElement?.querySelectorAll('button')).toHaveLength(1)
})
it('is absent outside the main session list and for subagent-scoped tool ids', () => {
renderTool('toolu_1', false)
expect(screen.queryByRole('button', { name: 'View in trajectory' })).not.toBeInTheDocument()
renderTool('agent-a/toolu_2', true)
expect(screen.queryByRole('button', { name: 'View in trajectory' })).not.toBeInTheDocument()
renderTool(undefined, true)
expect(screen.queryByRole('button', { name: 'View in trajectory' })).not.toBeInTheDocument()
})
})
@@ -0,0 +1,34 @@
import { createContext, useContext } from 'react'
import { ListTree } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { IconButton } from '@/components/ui/IconButton'
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
/**
* Provided by the main session's message list. Tool cards render a "view in
* trajectory" action only inside it — side chats, subagent transcripts and
* mobile layouts have no trajectory tab to jump to.
*/
export const TrajectoryLinkContext = createContext<{ sessionId: string } | null>(null)
export function ViewInTrajectoryButton({ toolUseId, className }: { toolUseId: string; className?: string }) {
const t = useTranslation()
const link = useContext(TrajectoryLinkContext)
const reveal = useTrajectoryViewStore((state) => state.revealInTrajectory)
// Namespaced ids belong to a subagent's own transcript, not this session's.
if (!link || toolUseId.includes('/')) return null
return (
<IconButton
icon={<ListTree size={13} />}
label={t('trajectory.viewInTrajectory')}
size="xs"
tone="muted"
className={className}
data-testid="view-in-trajectory"
onClick={(event) => {
event.stopPropagation()
reveal(link.sessionId, `t:${toolUseId}`)
}}
/>
)
}
@@ -0,0 +1,132 @@
import { memo, useRef, useState, type MouseEvent } from 'react'
import { useTranslation } from '../../i18n'
import { cx } from '@/lib/cx'
import { rowAtFraction, type MinimapModel } from '../../lib/trajectory/minimap'
import type { TrajectoryLane } from '../../lib/trajectory/viewModel'
import type { TrajectoryRow } from '../../types/trajectory'
import { KIND_FILL_CLASSES, KIND_LABEL_KEYS } from './trajectoryLabels'
const LANE_HEIGHT = 14
type Props = {
model: MinimapModel
/** Visible slice of the table as fractions of the plot, if known. */
viewport: { x: number; w: number } | null
rowsById: ReadonlyMap<string, TrajectoryRow>
onSelect: (rowId: string) => void
}
type Hover = { left: number; rowId: string }
function hitTest(model: MinimapModel, event: MouseEvent<HTMLDivElement>): string | null {
const hit = (event.target as HTMLElement).dataset?.rowId
if (hit) return hit
const rect = event.currentTarget.getBoundingClientRect()
if (!rect.width) return null
const x = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width))
const laneIndex = Math.floor((event.clientY - rect.top) / LANE_HEIGHT)
const lane = laneIndex >= 0 && laneIndex <= 2 ? (laneIndex as TrajectoryLane) : null
return rowAtFraction(model, x, lane) ?? rowAtFraction(model, x, null)
}
/**
* Three lanes (input / model / tools) across the loaded session. Spans are
* pre-bucketed by `buildMinimap`, so the DOM stays bounded however long the
* session is; spans take their row kind's badge color, and failures get
* full-height marks so they stay visible at any scale.
*/
export const TrajectoryMinimap = memo(function TrajectoryMinimap({ model, viewport, rowsById, onSelect }: Props) {
const t = useTranslation()
const [hover, setHover] = useState<Hover | null>(null)
const frame = useRef(0)
const lanes: Array<{ lane: TrajectoryLane; label: string }> = [
{ lane: 0, label: t('trajectory.minimap.input') },
{ lane: 1, label: t('trajectory.minimap.model') },
{ lane: 2, label: t('trajectory.minimap.tools') },
]
const handleMove = (event: MouseEvent<HTMLDivElement>) => {
const rect = event.currentTarget.getBoundingClientRect()
const left = event.clientX - rect.left
const rowId = hitTest(model, event)
cancelAnimationFrame(frame.current)
frame.current = requestAnimationFrame(() => setHover(rowId ? { left, rowId } : null))
}
const hoverRow = hover ? rowsById.get(hover.rowId) : undefined
return (
<div className="flex shrink-0 gap-2 border-b border-[var(--color-border)] px-3 py-1.5" data-testid="trajectory-minimap">
<div className="flex w-9 shrink-0 flex-col text-right text-[10px] leading-[14px] text-[var(--color-text-tertiary)]" aria-hidden>
{lanes.map(({ lane, label }) => <span key={lane}>{label}</span>)}
</div>
<div
className="relative min-w-0 flex-1 cursor-pointer"
style={{ height: LANE_HEIGHT * 3 }}
onClick={(event) => {
const rowId = hitTest(model, event)
if (rowId) onSelect(rowId)
}}
onMouseMove={handleMove}
onMouseLeave={() => {
cancelAnimationFrame(frame.current)
setHover(null)
}}
role="img"
aria-label={t('trajectory.minimap.label')}
>
{model.turnMarks.map((x, index) => (
<span key={`turn-${index}`} aria-hidden className="absolute inset-y-0 w-px bg-[var(--color-border)]" style={{ left: `${x * 100}%` }} />
))}
{viewport && (
<span
aria-hidden
data-testid="trajectory-minimap-viewport"
className="absolute inset-y-0 rounded-[var(--radius-sm)] border border-[var(--color-border-focus)] bg-[var(--color-surface-hover)]"
style={{ left: `${viewport.x * 100}%`, width: `max(${viewport.w * 100}%, 2px)` }}
/>
)}
{model.runs.map((run) => (
<span
key={`${run.lane}:${run.x}`}
aria-hidden
data-row-id={run.rowId}
data-kind={run.kind}
className={cx(
'trajectory-minimap-span absolute rounded-[1px]',
run.error ? 'bg-[var(--color-error)]' : KIND_FILL_CLASSES[run.kind],
run.match && 'ring-1 ring-[var(--color-text-primary)]',
run.selected && 'ring-2 ring-[var(--color-text-primary)]',
)}
style={{
left: `${run.x * 100}%`,
width: `max(${run.w * 100}%, 2px)`,
top: run.lane * LANE_HEIGHT + 3,
height: LANE_HEIGHT - 6,
}}
/>
))}
{model.errorMarks.map((mark) => (
<span
key={`error-${mark.x}`}
aria-hidden
data-row-id={mark.rowId}
data-testid="trajectory-minimap-error"
className="absolute inset-y-0 w-[2px] -translate-x-1/2 bg-[var(--color-error)]"
style={{ left: `${mark.x * 100}%` }}
/>
))}
{hover && hoverRow && (
<div
role="tooltip"
className="pointer-events-none absolute top-full z-[var(--z-tooltip)] mt-1 max-w-[320px] -translate-x-1/2 truncate rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 text-[11px] text-[var(--color-text-primary)] shadow-[var(--shadow-dropdown)]"
style={{ left: hover.left }}
>
<span className="text-[var(--color-text-tertiary)]">{t(KIND_LABEL_KEYS[hoverRow.kind])} · </span>
{hoverRow.toolName ? `${hoverRow.toolName} ${hoverRow.inputSummary ?? ''}` : hoverRow.preview || hoverRow.label || ''}
</div>
)}
</div>
</div>
)
})
@@ -0,0 +1,378 @@
import { Fragment, memo, useEffect, useRef, type KeyboardEvent, type ReactNode } from 'react'
import { ChevronDown, ChevronRight, CircleX } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { Badge } from '@/components/ui/Badge'
import { cx } from '@/lib/cx'
import { formatBytes } from '../../lib/formatBytes'
import { formatDurationMs, formatTokenCount } from '../../lib/trace/formatters'
import { hasUsage, isOrphanTool, rowDurationMs, toolHasResult, type TrajectoryItem } from '../../lib/trajectory/viewModel'
import { useFixedRowWindow, visibleRange, type ScrollAlign } from '../../lib/trajectory/useFixedRowWindow'
import type { TrajectoryRow } from '../../types/trajectory'
import { KIND_LABEL_KEYS, KIND_TONES, KIND_VARIANTS, contextLabel, systemRowTitle, turnLabel, userLabel } from './trajectoryLabels'
export const TRAJECTORY_ROW_HEIGHT = 30
export type TableScrollRequest = { index: number; nonce: number; align: ScrollAlign; offset?: number }
export type TableRange = {
/** First and last visible item index (no overscan). */
first: number
last: number
/** Pixel distance of the first visible item's top from the viewport top. */
firstOffset: number
atBottom: boolean
}
type Props = {
items: readonly TrajectoryItem[]
selectedId: string | null
collapsedTurns: ReadonlySet<number>
/** Search terms to highlight in visible rows. */
terms: readonly string[]
/** A turn is running, so a tool without a result is still executing. */
running: boolean
onSelect: (rowId: string) => void
onToggleTurn: (turn: number) => void
onEscape: () => void
scrollRequest: TableScrollRequest | null
onRangeChange?: (range: TableRange) => void
/** Rendered as the first line when older history exists. */
header?: ReactNode
}
export function TrajectoryTable({
items,
selectedId,
collapsedTurns,
terms,
running,
onSelect,
onToggleTurn,
onEscape,
scrollRequest,
onRangeChange,
header,
}: Props) {
const t = useTranslation()
const scrollerRef = useRef<HTMLDivElement>(null)
const headerRows = header ? 1 : 0
const total = items.length + headerRows
const { start, end, scrollTop, viewportHeight, scrollToIndex } = useFixedRowWindow(scrollerRef, { count: total, rowHeight: TRAJECTORY_ROW_HEIGHT })
useEffect(() => {
if (!scrollRequest) return
scrollToIndex(scrollRequest.index + headerRows, scrollRequest.align, scrollRequest.offset)
}, [headerRows, scrollRequest, scrollToIndex])
useEffect(() => {
if (!onRangeChange) return
const visible = visibleRange(scrollTop, viewportHeight, TRAJECTORY_ROW_HEIGHT, total)
const first = Math.max(0, visible.first - headerRows)
onRangeChange({
first,
last: Math.max(first, visible.last - headerRows),
firstOffset: (first + headerRows) * TRAJECTORY_ROW_HEIGHT - scrollTop,
atBottom: visible.last >= total - 1,
})
}, [headerRows, onRangeChange, scrollTop, total, viewportHeight])
const selectedIndex = selectedId ? items.findIndex((item) => item.type === 'row' && item.row.id === selectedId) : -1
const pageRows = Math.max(1, Math.floor(viewportHeight / TRAJECTORY_ROW_HEIGHT) - 1)
const selectAt = (target: number, direction: 1 | -1) => {
let index = Math.max(0, Math.min(items.length - 1, target))
while (index >= 0 && index < items.length && items[index]!.type !== 'row') index += direction
if (index < 0 || index >= items.length) {
index = Math.max(0, Math.min(items.length - 1, target))
while (index >= 0 && index < items.length && items[index]!.type !== 'row') index -= direction
}
const item = items[index]
if (item?.type === 'row') {
onSelect(item.row.id)
scrollToIndex(index + headerRows)
}
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const from = selectedIndex < 0 ? -1 : selectedIndex
switch (event.key) {
case 'ArrowDown': selectAt(from + 1, 1); break
case 'ArrowUp': selectAt(from < 0 ? items.length - 1 : from - 1, -1); break
case 'PageDown': selectAt(from + pageRows, 1); break
case 'PageUp': selectAt(Math.max(0, from - pageRows), -1); break
case 'Home': selectAt(0, 1); break
case 'End': selectAt(items.length - 1, -1); break
case 'Escape': onEscape(); break
default: return
}
event.preventDefault()
}
const rendered = []
let selectedRendered = false
for (let index = start; index < end; index++) {
const top = index * TRAJECTORY_ROW_HEIGHT
if (headerRows && index === 0) {
rendered.push(
<div key="__header" role="presentation" className="absolute inset-x-0 flex items-center justify-center" style={{ top, height: TRAJECTORY_ROW_HEIGHT }}>
{header}
</div>,
)
continue
}
const item = items[index - headerRows]
if (!item) continue
if (item.type === 'row' && item.row.id === selectedId) selectedRendered = true
rendered.push(
<div key={item.key} role="presentation" className="absolute inset-x-0" style={{ top, height: TRAJECTORY_ROW_HEIGHT }}>
{item.type === 'row' ? (
<TrajectoryRowView
item={item}
selected={item.row.id === selectedId}
collapsed={item.row.turn !== null && collapsedTurns.has(item.row.turn)}
terms={terms}
running={running}
onSelect={onSelect}
onToggleTurn={onToggleTurn}
/>
) : (
<button
type="button"
onClick={() => onToggleTurn(item.turn)}
className="flex h-full w-full items-center gap-2 pl-[120px] pr-3 text-left text-xs text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<span>{t('trajectory.table.folded', { count: item.hiddenRows })}</span>
{item.toolCount > 0 && <span>· {t('trajectory.table.toolCount', { count: item.toolCount })}</span>}
{item.errors > 0 && <span className="text-[var(--color-error)]">· {t('trajectory.table.errorCount', { count: item.errors })}</span>}
{item.durationMs > 0 && <span>· {formatDurationMs(item.durationMs)}</span>}
</button>
)}
</div>,
)
}
return (
<div
ref={scrollerRef}
role="listbox"
aria-label={t('trajectory.table.label')}
aria-activedescendant={selectedId && selectedRendered ? rowDomId(selectedId) : undefined}
tabIndex={0}
onKeyDown={handleKeyDown}
data-testid="trajectory-table"
className="relative min-h-0 flex-1 overflow-y-auto overflow-x-hidden focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<div className="relative" style={{ height: total * TRAJECTORY_ROW_HEIGHT }}>
{rendered}
</div>
</div>
)
}
function rowDomId(rowId: string) {
return `trajectory-row-${rowId.replace(/[^A-Za-z0-9_-]/g, '_')}`
}
type RowProps = {
item: Extract<TrajectoryItem, { type: 'row' }>
selected: boolean
collapsed: boolean
terms: readonly string[]
running: boolean
onSelect: (rowId: string) => void
onToggleTurn: (turn: number) => void
}
const TrajectoryRowView = memo(function TrajectoryRowView({ item, selected, collapsed, terms, running, onSelect, onToggleTurn }: RowProps) {
const t = useTranslation()
const { row, turnStart, hiddenTools } = item
const duration = rowDurationMs(row)
return (
<div
className={cx(
'group flex h-full items-center border-l-2 pr-3 text-[13px]',
selected
? 'border-l-[var(--color-brand)] bg-[var(--color-surface-selected)]'
: 'border-l-transparent hover:bg-[var(--color-surface-hover)]',
turnStart && 'border-t border-t-[var(--color-border)]',
)}
>
{/* The fold toggle sits beside the option, not inside it: an option may not contain other controls. */}
<div className="flex w-[72px] shrink-0 items-center justify-end pr-1">
{turnStart && row.turn !== null && (
<button
type="button"
onClick={() => onToggleTurn(row.turn!)}
aria-expanded={!collapsed}
aria-label={t(collapsed ? 'trajectory.table.expandTurn' : 'trajectory.table.collapseTurn', { turn: turnLabel(row.turn, t) })}
className="flex items-center gap-0.5 rounded-[var(--radius-sm)] px-1 text-[10.5px] font-medium text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{collapsed ? <ChevronRight size={10} aria-hidden /> : <ChevronDown size={10} aria-hidden />}
<span className="whitespace-nowrap">{turnLabel(row.turn, t)}</span>
</button>
)}
</div>
<div
id={rowDomId(row.id)}
role="option"
aria-selected={selected}
data-row-id={row.id}
data-kind={row.kind}
onClick={() => onSelect(row.id)}
className="flex h-full min-w-0 flex-1 cursor-default items-center gap-2"
>
<Badge tone={row.isError ? 'danger' : KIND_TONES[row.kind]} variant={KIND_VARIANTS[row.kind]} size="xs" pill={false} className="min-w-11 justify-center">
{t(KIND_LABEL_KEYS[row.kind])}
</Badge>
{row.isError && <CircleX size={13} aria-label={t('trajectory.status.error')} className="shrink-0 text-[var(--color-error)]" />}
<div className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
<RowContent row={row} hiddenTools={hiddenTools} terms={terms} running={running} />
</div>
{row.kind === 'assistant' && hasUsage(row) && (
<span
className="shrink-0 font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]"
title={t('trajectory.table.usageTitle')}
>
{t('trajectory.table.usageShort', {
input: formatTokenCount(row.usage!.input + row.usage!.cacheRead + row.usage!.cacheWrite),
output: formatTokenCount(row.usage!.output),
})}
</span>
)}
<span className="w-14 shrink-0 text-right font-mono text-[11px] tabular-nums text-[var(--color-text-tertiary)]">
{duration !== null ? formatDurationMs(duration) : ''}
</span>
</div>
</div>
)
})
/** Wraps search hits in <mark>; only visible rows render, so this stays cheap. */
function Highlight({ text, terms }: { text: string; terms: readonly string[] }) {
if (!terms.length || !text) return <>{text}</>
const lower = text.toLowerCase()
const marks: Array<[number, number]> = []
for (const term of terms) {
let at = lower.indexOf(term)
while (at >= 0 && marks.length < 32) {
marks.push([at, at + term.length])
at = lower.indexOf(term, at + term.length)
}
}
if (!marks.length) return <>{text}</>
marks.sort((a, b) => a[0] - b[0])
const parts: ReactNode[] = []
let cursor = 0
marks.forEach(([from, to], index) => {
if (from < cursor) return
if (from > cursor) parts.push(text.slice(cursor, from))
parts.push(<mark key={index} className="rounded-[2px] bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]">{text.slice(from, to)}</mark>)
cursor = to
})
parts.push(text.slice(cursor))
return <>{parts.map((part, index) => <Fragment key={index}>{part}</Fragment>)}</>
}
function RowContent({ row, hiddenTools, terms, running }: {
row: TrajectoryRow
hiddenTools: Extract<TrajectoryItem, { type: 'row' }>['hiddenTools']
terms: readonly string[]
running: boolean
}) {
const t = useTranslation()
const muted = 'truncate text-[var(--color-text-tertiary)]'
switch (row.kind) {
case 'system': {
const details = [
row.snapshot?.sysChars ? t('trajectory.system.chars', { count: row.snapshot.sysChars.toLocaleString() }) : null,
row.snapshot?.toolCount ? t('trajectory.table.toolCount', { count: row.snapshot.toolCount }) : null,
row.snapshot?.scope && row.snapshot.scope !== 'main' ? row.snapshot.scope : null,
].filter(Boolean).join(' · ')
return (
<>
<span className="shrink-0 text-[var(--color-text-primary)]">{systemRowTitle(row, t)}</span>
{details && <span className={muted}>{details}</span>}
</>
)
}
case 'context': {
const label = contextLabel(row, t)
return (
<>
{label && <span className="shrink-0 text-[var(--color-text-secondary)]">{label}</span>}
<span className={muted}><Highlight text={row.preview} terms={terms} /></span>
</>
)
}
case 'user': {
const label = userLabel(row, t)
return (
<>
{label && <span className="shrink-0 text-[11px] text-[var(--color-text-tertiary)]">{label}</span>}
<span className="truncate text-[var(--color-text-primary)]"><Highlight text={row.preview} terms={terms} /></span>
</>
)
}
case 'assistant':
return (
<>
{row.preview
? <span className="min-w-0 truncate text-[var(--color-text-primary)]"><Highlight text={row.preview} terms={terms} /></span>
: <span className={muted}>{t(row.hasThinking && !row.toolOnly ? 'trajectory.assistant.thinkingOnly' : 'trajectory.assistant.toolOnly')}</span>}
{hiddenTools && (
<span className="flex min-w-0 shrink items-center gap-1.5 truncate font-mono text-[11.5px] text-[var(--color-text-secondary)]">
{hiddenTools.names.slice(0, 4).map(([name, count]) => (
<span key={name} className="shrink-0">{count > 1 ? `${name} ×${count}` : name}</span>
))}
{hiddenTools.names.length > 4 && <span className="shrink-0">…</span>}
{hiddenTools.errors > 0 && (
<span className="shrink-0 font-sans text-[var(--color-error)]">{t('trajectory.table.errorCount', { count: hiddenTools.errors })}</span>
)}
</span>
)}
</>
)
case 'tool': {
if (isOrphanTool(row)) {
return (
<>
<span className={muted}>{t('trajectory.tool.callEarlier')}</span>
{row.resultPreview && <span className={cx('min-w-0 flex-1 truncate font-mono text-[12px]', row.isError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]')}>→ {row.resultPreview}</span>}
</>
)
}
const argument = row.inputSummary ?? row.inputPreview
const result = row.resultOmittedBytes
? t('trajectory.tool.resultTooLarge', { size: formatBytes(row.resultOmittedBytes) })
: toolHasResult(row)
? row.resultPreview || t('trajectory.tool.emptyResult')
: null
return (
<>
<span className="shrink-0 font-mono text-[12px] font-medium text-[var(--color-text-primary)]">{row.toolName}</span>
{argument && (
// Sized to its text (up to 60%), so a short command sits right next to its result.
<span className="min-w-0 max-w-[60%] shrink truncate font-mono text-[12px] text-[var(--color-text-secondary)]" title={argument}>
<Highlight text={argument} terms={terms} />
</span>
)}
{result !== null ? (
<span className={cx('min-w-0 flex-1 truncate font-mono text-[12px]', row.isError ? 'text-[var(--color-error)]' : 'text-[var(--color-text-tertiary)]')}>
<span aria-hidden className="mr-1.5 font-sans">→</span>
<Highlight text={result} terms={terms} />
</span>
) : (
<span className={muted}>{t(running ? 'trajectory.tool.running' : 'trajectory.tool.noResult')}</span>
)}
</>
)
}
case 'compact':
return (
<>
<span className="shrink-0 text-[var(--color-text-primary)]">{t('trajectory.compact.title')}</span>
{row.preview && <span className={muted}>{row.preview}</span>}
</>
)
}
}
@@ -0,0 +1,128 @@
import type { ReactNode } from 'react'
import { ChevronRight, Clock, ListCollapse, Wrench } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { SearchField } from '@/components/ui/SearchField'
import { cx } from '@/lib/cx'
import { formatTokenCount } from '../../lib/trace/formatters'
import type { TrajectoryTotals } from '../../lib/trajectory/viewModel'
type Props = {
durationMode: boolean
onDurationModeChange: (value: boolean) => void
turnsCollapsed: boolean
onTurnsCollapsedChange: (value: boolean) => void
toolsHidden: boolean
onToolsHiddenChange: (value: boolean) => void
query: string
onQueryChange: (value: string) => void
/** Matching rows while a search is active. */
matchCount: number | null
totals: TrajectoryTotals
/** Older history is not loaded yet, so the totals cover only part of the session. */
totalsPartial: boolean
/** Subagent breadcrumb: shown when viewing a subagent's trajectory. */
agentLabel: string | null
onExitAgent: () => void
}
/** A labeled on/off toggle; icon-only toggles were too hard to discover. */
function ToggleButton({ pressed, onClick, icon, children, title }: { pressed: boolean; onClick: () => void; icon: ReactNode; children: ReactNode; title?: string }) {
return (
<button
type="button"
aria-pressed={pressed}
onClick={onClick}
title={title}
className={cx(
'inline-flex h-7 shrink-0 items-center gap-1.5 rounded-[var(--radius-md)] px-2 text-xs transition-colors active:scale-[0.98] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]',
pressed
? 'bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)]',
)}
>
{icon}
{children}
</button>
)
}
export function TrajectoryToolbar({
durationMode,
onDurationModeChange,
turnsCollapsed,
onTurnsCollapsedChange,
toolsHidden,
onToolsHiddenChange,
query,
onQueryChange,
matchCount,
totals,
totalsPartial,
agentLabel,
onExitAgent,
}: Props) {
const t = useTranslation()
const cacheBase = totals.input + totals.cacheRead + totals.cacheWrite
const cacheHit = cacheBase > 0 ? (totals.cacheRead / cacheBase) * 100 : null
const summary = [
totalsPartial && totals.firstTurn !== null ? t('trajectory.toolbar.loadedFrom', { turn: totals.firstTurn }) : null,
t('trajectory.toolbar.totals', {
turns: totals.turns,
steps: totals.steps,
tools: totals.tools,
tokens: formatTokenCount(cacheBase + totals.output),
}),
totals.errors > 0 ? t('trajectory.table.errorCount', { count: totals.errors }) : null,
cacheHit !== null ? t('trajectory.toolbar.cacheHit', { percent: cacheHit.toFixed(cacheHit >= 99.95 ? 0 : 1) }) : null,
].filter(Boolean).join(' · ')
return (
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-[var(--color-border)] px-3">
<ToggleButton
pressed={durationMode}
onClick={() => onDurationModeChange(!durationMode)}
icon={<Clock size={13} aria-hidden />}
title={t('trajectory.toolbar.durationHint')}
>
{t('trajectory.toolbar.duration')}
</ToggleButton>
<ToggleButton pressed={turnsCollapsed} onClick={() => onTurnsCollapsedChange(!turnsCollapsed)} icon={<ListCollapse size={13} aria-hidden />}>
{t('trajectory.toolbar.collapseTurns')}
</ToggleButton>
<ToggleButton pressed={toolsHidden} onClick={() => onToolsHiddenChange(!toolsHidden)} icon={<Wrench size={13} aria-hidden />}>
{t('trajectory.toolbar.hideTools')}
</ToggleButton>
{agentLabel && (
<nav aria-label={t('trajectory.subagent.breadcrumb')} className="ml-2 flex min-w-0 items-center gap-1 text-xs">
<button
type="button"
onClick={onExitAgent}
className="shrink-0 rounded-[var(--radius-sm)] px-1 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{t('trajectory.subagent.main')}
</button>
<ChevronRight size={12} aria-hidden className="shrink-0 text-[var(--color-text-tertiary)]" />
<span className="truncate font-medium text-[var(--color-text-primary)]">{agentLabel}</span>
</nav>
)}
<div className="ml-auto flex min-w-0 items-center gap-3">
<span
className="hidden min-w-0 truncate text-[11px] tabular-nums text-[var(--color-text-tertiary)] lg:inline"
data-testid="trajectory-totals"
title={t('trajectory.toolbar.totalsHint')}
>
{matchCount !== null ? t('trajectory.toolbar.matches', { count: matchCount }) : summary}
</span>
<SearchField
value={query}
onChange={onQueryChange}
label={t('trajectory.toolbar.search')}
placeholder={t('trajectory.toolbar.search')}
clearable
clearLabel={t('trajectory.toolbar.clearSearch')}
size="sm"
containerClassName="w-44"
/>
</div>
</div>
)
}
@@ -0,0 +1,286 @@
import '@testing-library/jest-dom'
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TrajectoryPage, TrajectoryRow, TrajectoryRowDetail, TrajectorySnapshotBlob } from '../../types/trajectory'
const getPage = vi.fn<(sessionId: string, page?: { cursor?: string; after?: string; agentId?: string }) => Promise<TrajectoryPage>>()
const getRow = vi.fn<(sessionId: string, rowId: string) => Promise<TrajectoryRowDetail>>()
const getSnapshot = vi.fn<(sessionId: string, hash: string) => Promise<TrajectorySnapshotBlob>>()
vi.mock('../../api/trajectory', () => ({
trajectoryApi: {
getPage: (sessionId: string, page?: { cursor?: string; after?: string; agentId?: string }) => getPage(sessionId, page),
getRow: (sessionId: string, rowId: string) => getRow(sessionId, rowId),
getSnapshot: (sessionId: string, hash: string) => getSnapshot(sessionId, hash),
},
}))
import TrajectoryView from './TrajectoryView'
import { clearTrajectoryDataCache } from './useTrajectoryData'
import { clearTrajectoryDetailCache } from './useTrajectoryDetail'
import { useSettingsStore } from '../../stores/settingsStore'
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
function at(seconds: number) {
return new Date(Date.UTC(2026, 9, 4, 10, 0, 0) + seconds * 1000).toISOString()
}
let offset = 0
function row(partial: Partial<TrajectoryRow> & Pick<TrajectoryRow, 'id' | 'kind'>): TrajectoryRow {
offset += 100
return { turn: 1, ts: at(offset / 100), preview: '', loc: [[offset, offset + 50]], ...partial }
}
function page(rows: TrajectoryRow[], extra: Partial<TrajectoryPage> = {}): TrajectoryPage {
return { rows, turnBase: 0, olderCursor: null, tailToken: 'tail-1', historyComplete: true, omittedOversizedEntries: 0, ...extra }
}
const SNAPSHOT = row({
id: 's:main:0',
kind: 'system',
turn: null,
// Written when the request is sent: after the prompt and its attachments, before the response.
ts: at(2.5),
loc: [],
label: 'initial',
preview: 'system prompt',
snapshot: { scope: 'main', change: 'initial', sys: 'aaaaaaaaaaaaaaaa', tools: 'bbbbbbbbbbbbbbbb', toolCount: 2, sysChars: 1234 },
})
function baseRows() {
offset = 0
return [
row({ id: 'u:p1', kind: 'user', preview: 'Build the console' }),
row({ id: 'c:skills', kind: 'context', label: 'skill_listing', attachmentType: 'skill_listing', preview: '3 skills' }),
row({ id: 'a:m1', kind: 'assistant', preview: 'Checking the project', usage: { input: 10, output: 20, cacheRead: 1000, cacheWrite: 0 }, model: 'claude-x' }),
row({ id: 't:toolu_1', kind: 'tool', toolName: 'Bash', toolUseId: 'toolu_1', parentId: 'a:m1', inputPreview: '{"command":"ls -la"}', resultPreview: 'package.json', resultTs: at(9) }),
]
}
function renderView(props: Partial<{ visible: boolean; running: boolean; activityKey: number }> = {}) {
return render(
<TrajectoryView sessionId="session-1" visible={props.visible ?? true} running={props.running ?? false} activityKey={props.activityKey ?? 0} />,
)
}
describe('TrajectoryView', () => {
beforeEach(() => {
clearTrajectoryDataCache()
clearTrajectoryDetailCache()
getPage.mockReset()
getRow.mockReset()
getSnapshot.mockReset()
useSettingsStore.setState({ locale: 'en' })
useTrajectoryViewStore.setState({ modes: {}, opened: {}, nav: null })
})
afterEach(() => {
vi.useRealTimers()
})
it('fetches nothing while hidden', () => {
getPage.mockResolvedValue(page(baseRows()))
renderView({ visible: false })
expect(getPage).not.toHaveBeenCalled()
})
it('renders every row kind as one ledger line, with the snapshot placed before the response', async () => {
getPage.mockResolvedValue(page(baseRows(), { snapshots: [SNAPSHOT] }))
renderView()
const table = await screen.findByRole('listbox', { name: 'Trajectory' })
const options = within(table).getAllByRole('option')
expect(options.map((option) => option.dataset.rowId)).toEqual(['u:p1', 'c:skills', 's:main:0', 'a:m1', 't:toolu_1'])
expect(within(options[1]!).getByText('Skill list')).toBeInTheDocument()
expect(within(options[2]!).getByText('Initial system prompt')).toBeInTheDocument()
expect(within(options[4]!).getByText('Bash')).toBeInTheDocument()
expect(within(options[4]!).getByText('package.json')).toBeInTheDocument()
expect(within(table).getByText('Turn 1')).toBeInTheDocument()
// The sticky bar names the turn in view even when its first row has scrolled away.
expect(within(screen.getByTestId('trajectory-turn-bar')).getByText('Turn 1')).toBeInTheDocument()
expect(screen.getByTestId('trajectory-totals').textContent).toContain('1 turns · 1 steps · 1 tool calls')
})
it('opens a tool row in the detail panel with its input and result tabs', async () => {
getPage.mockResolvedValue(page(baseRows()))
getRow.mockResolvedValue({
rowId: 't:toolu_1',
truncated: false,
entries: [
{ type: 'assistant', uuid: 'e1', message: { content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls -la' } }] } },
{ type: 'user', uuid: 'e2', message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'package.json\nsrc', is_error: false }] } },
],
})
renderView()
fireEvent.click(await screen.findByText('package.json'))
const panel = await screen.findByTestId('trajectory-detail')
expect(within(panel).getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['Overview', 'Input', 'Result', 'Raw'])
fireEvent.click(within(panel).getByRole('tab', { name: 'Result' }))
expect(await within(panel).findByText(/package\.json\s+src/)).toBeInTheDocument()
expect(getRow).toHaveBeenCalledWith('session-1', 't:toolu_1')
})
it('shows the system prompt and tool catalog of a snapshot row', async () => {
getPage.mockResolvedValue(page(baseRows(), { snapshots: [SNAPSHOT] }))
getSnapshot.mockImplementation(async (_sessionId, hash) => hash === 'aaaaaaaaaaaaaaaa'
? { hash, kind: 'sys', text: 'You are Claude Code.' }
: { hash, kind: 'tools', json: [{ name: 'Bash', description: 'Run shell' }, { name: 'Read', description: 'Read files' }] })
renderView()
fireEvent.click(await screen.findByText('Initial system prompt'))
const panel = await screen.findByTestId('trajectory-detail')
expect(await within(panel).findByText('You are Claude Code.')).toBeInTheDocument()
fireEvent.click(within(panel).getByRole('tab', { name: 'Tools' }))
expect(await within(panel).findByText('Read')).toBeInTheDocument()
})
it('diffs an updated system prompt against the previous snapshot', async () => {
const updated = { ...SNAPSHOT, id: 's:main:1', ts: at(6), snapshot: { scope: 'main', change: 'system' as const, sys: 'cccccccccccccccc', tools: 'bbbbbbbbbbbbbbbb', prevSys: 'aaaaaaaaaaaaaaaa', prevTools: 'bbbbbbbbbbbbbbbb' } }
getPage.mockResolvedValue(page(baseRows(), { snapshots: [SNAPSHOT, updated] }))
getSnapshot.mockImplementation(async (_sessionId, hash) => (
hash === 'aaaaaaaaaaaaaaaa' ? { hash, kind: 'sys', text: 'line one\nold rule' }
: hash === 'cccccccccccccccc' ? { hash, kind: 'sys', text: 'line one\nnew rule' }
: { hash, kind: 'tools', json: [] }
))
renderView()
fireEvent.click(await screen.findByText('System prompt updated'))
const panel = await screen.findByTestId('trajectory-detail')
fireEvent.click(within(panel).getByRole('tab', { name: 'Diff' }))
const diff = await within(panel).findByTestId('trajectory-diff')
expect(diff.querySelector('[data-diff="remove"]')?.textContent).toContain('old rule')
expect(diff.querySelector('[data-diff="add"]')?.textContent).toContain('new rule')
})
it('folds a turn from its gutter label and hides tool rows from the toolbar', async () => {
const rows = baseRows()
rows[3] = { ...rows[3]!, isError: true }
getPage.mockResolvedValue(page(rows))
renderView()
await screen.findByText('Build the console')
const toggle = screen.getByRole('button', { name: /Hide tools/ })
fireEvent.click(toggle)
expect(toggle).toHaveAttribute('aria-pressed', 'true')
await waitFor(() => expect(screen.queryByText('package.json')).not.toBeInTheDocument())
// The response still says what it ran, and that it failed.
const response = screen.getByText('Checking the project').closest('[role=option]') as HTMLElement
expect(within(response).getByText('Bash')).toBeInTheDocument()
expect(within(response).getByText('1 failed')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Collapse Turn 1' }))
await waitFor(() => expect(screen.queryByText('Checking the project')).not.toBeInTheDocument())
// Context, response and the (already hidden) tool call all fold under the prompt.
expect(screen.getByText('3 more rows')).toBeInTheDocument()
})
it('selects the tool row a chat card asked to reveal', async () => {
getPage.mockResolvedValue(page(baseRows()))
getRow.mockResolvedValue({ rowId: 't:toolu_1', truncated: false, entries: [] })
renderView()
await screen.findByText('Build the console')
act(() => useTrajectoryViewStore.getState().revealInTrajectory('session-1', 't:toolu_1'))
await waitFor(() => expect(screen.getByRole('option', { selected: true }).dataset.rowId).toBe('t:toolu_1'))
expect(useTrajectoryViewStore.getState().nav).toBeNull()
})
it('asks the chat to scroll to the tool call from "Locate in chat"', async () => {
getPage.mockResolvedValue(page(baseRows()))
getRow.mockResolvedValue({ rowId: 't:toolu_1', truncated: false, entries: [] })
renderView()
fireEvent.click(await screen.findByText('package.json'))
fireEvent.click(await screen.findByRole('button', { name: 'Locate in chat' }))
await waitFor(() => expect(useTrajectoryViewStore.getState().nav).toMatchObject({
to: 'chat',
sessionId: 'session-1',
target: { toolUseId: 'toolu_1' },
}))
expect(useTrajectoryViewStore.getState().modes['session-1']).toBe('chat')
})
it('appends only new records while a turn runs', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true })
getPage.mockResolvedValueOnce(page(baseRows()))
const appended = row({ id: 'a:m2', kind: 'assistant', preview: 'All dependencies installed' })
getPage.mockResolvedValueOnce(page([appended], { tailToken: 'tail-2' }))
const view = renderView({ running: true, activityKey: 1 })
await screen.findByText('Build the console')
view.rerender(<TrajectoryView sessionId="session-1" visible running activityKey={2} />)
await act(async () => { await vi.advanceTimersByTimeAsync(1600) })
expect(getPage).toHaveBeenLastCalledWith('session-1', expect.objectContaining({ after: 'tail-1' }))
expect(await screen.findByText('All dependencies installed')).toBeInTheDocument()
})
it('switches to a subagent trajectory and back', async () => {
const rows = baseRows()
rows[3] = { ...rows[3]!, toolName: 'Agent', agentId: 'abc123', inputPreview: 'Explore the repo' }
getPage.mockImplementation(async (_sessionId, request) => request?.agentId
? page([row({ id: 'u:sub', kind: 'user', preview: 'Subagent prompt' })])
: page(rows))
getRow.mockResolvedValue({ rowId: 't:toolu_1', truncated: false, entries: [] })
renderView()
fireEvent.click(await screen.findByText('Agent'))
fireEvent.click(await screen.findByRole('button', { name: 'View subagent trajectory' }))
expect(await screen.findByText('Subagent prompt')).toBeInTheDocument()
expect(getPage).toHaveBeenLastCalledWith('session-1', expect.objectContaining({ agentId: 'abc123' }))
fireEvent.click(screen.getByRole('button', { name: 'Main session' }))
expect(await screen.findByText('Build the console')).toBeInTheDocument()
// The row that opened the subagent is selected again.
await waitFor(() => expect(screen.getByRole('option', { selected: true }).dataset.rowId).toBe('t:toolu_1'))
})
it('counts search matches, and says so when nothing matches', async () => {
getPage.mockResolvedValue(page(baseRows(), { olderCursor: 'older-1' }))
renderView()
await screen.findByText('Build the console')
fireEvent.change(screen.getByRole('searchbox', { name: 'Search trajectory' }), { target: { value: 'ls -la' } })
await waitFor(() => expect(screen.getByTestId('trajectory-totals')).toHaveTextContent('1 matches'))
fireEvent.change(screen.getByRole('searchbox', { name: 'Search trajectory' }), { target: { value: 'nothing-like-this' } })
expect(await screen.findByText('No matching records')).toBeInTheDocument()
// Older history is unloaded, so the empty state offers to load it.
expect(screen.getByText(/Only loaded records were searched/)).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Load earlier records' })).toBeInTheDocument()
})
it('keeps the selected row when the view changes around it', async () => {
getPage.mockResolvedValue(page(baseRows()))
getRow.mockResolvedValue({ rowId: 'c:skills', truncated: false, entries: [] })
renderView()
fireEvent.click(await screen.findByText('3 skills'))
fireEvent.change(screen.getByRole('searchbox', { name: 'Search trajectory' }), { target: { value: 'skills' } })
await waitFor(() => expect(screen.getByTestId('trajectory-totals')).toHaveTextContent('matches'))
fireEvent.change(screen.getByRole('searchbox', { name: 'Search trajectory' }), { target: { value: '' } })
await waitFor(() => expect(screen.getByTestId('trajectory-totals')).not.toHaveTextContent('matches'))
expect(screen.getByRole('option', { selected: true }).dataset.rowId).toBe('c:skills')
expect(screen.getByTestId('trajectory-detail')).toBeInTheDocument()
})
it('moves the selection with Home/End and closes the detail with Escape', async () => {
getPage.mockResolvedValue(page(baseRows()))
getRow.mockResolvedValue({ rowId: 'x', truncated: false, entries: [] })
renderView()
const table = await screen.findByRole('listbox', { name: 'Trajectory' })
fireEvent.keyDown(table, { key: 'End' })
expect(screen.getByRole('option', { selected: true }).dataset.rowId).toBe('t:toolu_1')
fireEvent.keyDown(table, { key: 'Home' })
expect(screen.getByRole('option', { selected: true }).dataset.rowId).toBe('u:p1')
expect(table).toHaveAttribute('aria-activedescendant')
expect(screen.getByTestId('trajectory-detail')).toBeInTheDocument()
fireEvent.keyDown(table, { key: 'Escape' })
expect(screen.queryByTestId('trajectory-detail')).not.toBeInTheDocument()
})
it('tells a tool that is still running from one that ended without a result', async () => {
offset = 0
const rows = [
row({ id: 'u:p1', kind: 'user', preview: 'Go' }),
row({ id: 't:open', kind: 'tool', toolName: 'Bash', toolUseId: 'open', inputSummary: 'sleep 100' }),
row({ id: 't:big', kind: 'tool', toolName: 'Screenshot', toolUseId: 'big', resultOmittedBytes: 9_925_821, resultTs: at(9) }),
row({ id: 't:orphan', kind: 'tool', toolUseId: 'orphan', resultPreview: 'ok shard 1500', resultTs: at(10), partial: true }),
]
getPage.mockResolvedValue(page(rows))
const view = renderView({ running: true })
expect(await screen.findByText('Running…')).toBeInTheDocument()
// The plain-text argument is shown instead of raw JSON.
expect(screen.getByText('sleep 100')).toBeInTheDocument()
expect(screen.getByText(/Result too large to load/)).toBeInTheDocument()
expect(screen.getByText('(call is in earlier history)')).toBeInTheDocument()
view.rerender(<TrajectoryView sessionId="session-1" visible running={false} activityKey={0} />)
expect(await screen.findByText('No result')).toBeInTheDocument()
})
})
@@ -0,0 +1,415 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { ChevronDown, ChevronRight } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { LoadingState } from '@/components/ui/LoadingState'
import { useTrajectoryViewStore } from '../../stores/trajectoryViewStore'
import { formatDurationMs } from '../../lib/trace/formatters'
import { buildMinimap } from '../../lib/trajectory/minimap'
import {
buildTrajectoryItems,
computeTotals,
computeTurnStats,
listTurns,
matchesQuery,
orderTrajectoryRows,
queryTerms,
rowSearchText,
} from '../../lib/trajectory/viewModel'
import type { TrajectoryRow } from '../../types/trajectory'
import { TrajectoryDetailPanel } from './TrajectoryDetailPanel'
import { TrajectoryMinimap } from './TrajectoryMinimap'
import { TrajectoryTable, type TableRange, type TableScrollRequest } from './TrajectoryTable'
import { TrajectoryToolbar } from './TrajectoryToolbar'
import { turnLabel } from './trajectoryLabels'
import { useTrajectoryData } from './useTrajectoryData'
import { trajectoryApi } from '../../api/trajectory'
/** Search text is derived once per row object; merged rows are new objects. */
const searchTextCache = new WeakMap<TrajectoryRow, string>()
function cachedSearchText(row: TrajectoryRow): string {
let text = searchTextCache.get(row)
if (text === undefined) {
text = rowSearchText(row)
searchTextCache.set(row, text)
}
return text
}
/** How many older pages a jump may pull in while looking for its row. */
const NAV_PAGE_BUDGET = 40
const SEARCH_DEBOUNCE_MS = 150
/** The ledger stays readable down to this width; below it the detail becomes a drawer. */
const LEDGER_MIN_WIDTH = 360
const DRAWER_BELOW = 760
type AgentFrame = { agentId: string; label: string; parentSelectedId: string | null }
type Props = {
sessionId: string
/** False while the chat tab is showing; nothing is fetched or polled then. */
visible: boolean
running: boolean
activityKey: string | number
}
export default function TrajectoryView({ sessionId, visible, running, activityKey }: Props) {
const t = useTranslation()
const [agentStack, setAgentStack] = useState<AgentFrame[]>([])
const agent = agentStack[agentStack.length - 1]
const agentId = agent?.agentId
const { state, loadOlder, reload } = useTrajectoryData({
sessionId,
agentId,
visible,
running: running && !agentId,
activityKey,
})
const [selectedId, setSelectedId] = useState<string | null>(null)
const [query, setQuery] = useState('')
const [debouncedQuery, setDebouncedQuery] = useState('')
const [durationMode, setDurationMode] = useState(false)
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(() => new Set())
const [toolsHidden, setToolsHidden] = useState(false)
const [scrollRequest, setScrollRequest] = useState<TableScrollRequest | null>(null)
const [pendingReveal, setPendingReveal] = useState<{ rowId: string; align: TableScrollRequest['align']; offset?: number } | null>(null)
const [range, setRange] = useState<TableRange | null>(null)
const [splitWidth, setSplitWidth] = useState(0)
const splitRef = useRef<HTMLDivElement>(null)
const tableWrapRef = useRef<HTMLDivElement>(null)
const detailWidth = useTrajectoryViewStore((s) => s.detailWidth)
const setDetailWidth = useTrajectoryViewStore((s) => s.setDetailWidth)
const revealInChat = useTrajectoryViewStore((s) => s.revealInChat)
const consumeNav = useTrajectoryViewStore((s) => s.consumeNav)
const nav = useTrajectoryViewStore((s) => (s.nav?.to === 'trajectory' && s.nav.sessionId === sessionId ? s.nav : null))
useEffect(() => {
const timer = setTimeout(() => setDebouncedQuery(query), SEARCH_DEBOUNCE_MS)
return () => clearTimeout(timer)
}, [query])
useLayoutEffect(() => {
const element = splitRef.current
if (!element) return
setSplitWidth(element.clientWidth)
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(() => setSplitWidth(element.clientWidth))
observer.observe(element)
return () => observer.disconnect()
}, [])
const hasOlder = Boolean(state.olderCursor)
const ordered = useMemo(
() => orderTrajectoryRows(state.rows, state.snapshots, { hasOlder }),
[hasOlder, state.rows, state.snapshots],
)
const rowsById = useMemo(() => new Map(ordered.map((row) => [row.id, row])), [ordered])
const items = useMemo(
() => buildTrajectoryItems(ordered, { collapsedTurns, hideTools: toolsHidden, query: debouncedQuery }, cachedSearchText),
[collapsedTurns, debouncedQuery, ordered, toolsHidden],
)
const itemIndexById = useMemo(() => {
const map = new Map<string, number>()
items.forEach((item, index) => { if (item.type === 'row') map.set(item.row.id, index) })
return map
}, [items])
const totals = useMemo(() => computeTotals(ordered), [ordered])
const turnStats = useMemo(() => computeTurnStats(ordered), [ordered])
const terms = useMemo(() => queryTerms(debouncedQuery), [debouncedQuery])
const minimap = useMemo(
() => buildMinimap(ordered, durationMode ? 'duration' : 'sequence', {
selectedId,
isMatch: terms.length ? (row) => matchesQuery(cachedSearchText(row), terms) : undefined,
}),
[durationMode, ordered, selectedId, terms],
)
const requestScroll = useCallback((index: number, align: TableScrollRequest['align'], offset?: number) => {
setScrollRequest((previous) => ({ index, align, offset, nonce: (previous?.nonce ?? 0) + 1 }))
}, [])
// Make a row visible even when folding or the tool filter hides it, then scroll to it.
const reveal = useCallback((rowId: string, align: TableScrollRequest['align'] = 'center') => {
const row = rowsById.get(rowId)
if (!row) return false
setSelectedId(rowId)
if (row.turn !== null && collapsedTurns.has(row.turn)) {
setCollapsedTurns((previous) => {
const next = new Set(previous)
next.delete(row.turn!)
return next
})
}
if (row.kind === 'tool' && toolsHidden) setToolsHidden(false)
setPendingReveal({ rowId, align })
return true
}, [collapsedTurns, rowsById, toolsHidden])
// Finish a reveal once the row has an index in the rendered list.
useEffect(() => {
if (!pendingReveal) return
const index = itemIndexById.get(pendingReveal.rowId)
if (index === undefined) return
setPendingReveal(null)
requestScroll(index, pendingReveal.align, pendingReveal.offset)
}, [itemIndexById, pendingReveal, requestScroll])
// View options reshape the list. Keep the reader where they were: the
// selected row if there is one, otherwise the row at the top of the view.
const viewKey = `${debouncedQuery}\0${toolsHidden}\0${[...collapsedTurns].join(',')}`
const lastViewKey = useRef(viewKey)
const previousItems = useRef(items)
useEffect(() => {
if (lastViewKey.current === viewKey) {
previousItems.current = items
return
}
lastViewKey.current = viewKey
const before = previousItems.current
previousItems.current = items
if (selectedId && itemIndexById.has(selectedId)) {
setPendingReveal({ rowId: selectedId, align: 'center' })
return
}
const anchor = range ? before[range.first] : undefined
if (anchor?.type === 'row' && itemIndexById.has(anchor.row.id)) {
setPendingReveal({ rowId: anchor.row.id, align: 'offset', offset: range!.firstOffset })
} else if (items.length) {
requestScroll(0, 'offset', 0)
}
}, [itemIndexById, items, range, requestScroll, selectedId, viewKey])
// Follow the tail only when data grows (new records), never on a view change.
const atBottom = useRef(true)
useEffect(() => {
if (range) atBottom.current = range.atBottom
}, [range])
const lastRowCount = useRef(0)
useEffect(() => {
const grew = ordered.length > lastRowCount.current
const first = lastRowCount.current === 0
lastRowCount.current = ordered.length
if (!grew || pendingReveal) return
if ((first || atBottom.current) && items.length) requestScroll(items.length - 1, 'end')
// `items` is read for its length at the moment rows grow.
}, [ordered.length, pendingReveal, requestScroll, items.length])
// Jump requests from the chat ("view in trajectory").
const navAttempts = useRef(0)
useEffect(() => {
if (!nav || nav.to !== 'trajectory') return
if (agentStack.length) {
setAgentStack([])
return
}
if (state.status === 'loading' && state.version === 0) return
if (reveal(nav.rowId)) {
navAttempts.current = 0
consumeNav(nav.nonce)
// Keyboard users land on the list, ready to move through it.
requestAnimationFrame(() => tableWrapRef.current?.querySelector<HTMLElement>('[role=listbox]')?.focus({ preventScroll: true }))
return
}
if (state.olderCursor && !state.loadingOlder && navAttempts.current < NAV_PAGE_BUDGET) {
navAttempts.current++
void loadOlder()
return
}
if (!state.loadingOlder) {
navAttempts.current = 0
consumeNav(nav.nonce)
}
}, [agentStack.length, consumeNav, loadOlder, nav, reveal, state.loadingOlder, state.olderCursor, state.status, state.version])
// Coming back from a subagent: reselect the row that opened it once the main rows are in.
const [pendingRestore, setPendingRestore] = useState<string | null>(null)
useEffect(() => {
if (!pendingRestore || agentId) return
if (reveal(pendingRestore)) setPendingRestore(null)
}, [agentId, pendingRestore, reveal])
const selectedRow = selectedId ? rowsById.get(selectedId) ?? null : null
const handleLoadOlder = useCallback(async () => {
// Anchor on the first transcript row in view, at its current distance from the top.
const anchorItem = range ? items[range.first] : items[0]
const anchor = anchorItem?.type === 'row' ? anchorItem.row.id : null
const offset = range?.firstOffset ?? 0
const loaded = await loadOlder()
if (loaded && anchor) setPendingReveal({ rowId: anchor, align: 'offset', offset })
}, [items, loadOlder, range])
const handleToggleTurn = useCallback((turn: number) => {
setCollapsedTurns((previous) => {
const next = new Set(previous)
if (next.has(turn)) next.delete(turn)
else next.add(turn)
return next
})
}, [])
const handleLocateInChat = useCallback(async (row: TrajectoryRow) => {
let uuids: string[] = []
if (row.kind !== 'tool' && row.loc.length) {
try {
const detail = await trajectoryApi.getRow(sessionId, row.id, row.loc)
uuids = detail.entries.map((entry) => entry.uuid).filter((value): value is string => typeof value === 'string')
} catch {
// Without uuids the user row id still carries one.
}
if (row.id.startsWith('u:')) uuids.push(row.id.slice(2))
}
revealInChat(sessionId, { toolUseId: row.toolUseId, uuids })
}, [revealInChat, sessionId])
const viewport = useMemo(() => {
if (!range || !items.length) return null
const slice = items.slice(range.first, range.last + 1)
const first = slice.find((item) => item.type === 'row')
const last = [...slice].reverse().find((item) => item.type === 'row')
if (first?.type !== 'row' || last?.type !== 'row') return null
const a = minimap.positions.get(first.row.id)
const b = minimap.positions.get(last.row.id)
if (!a || !b) return null
return { x: a.x, w: Math.max(0, b.x + b.w - a.x) }
}, [items, minimap.positions, range])
const currentTurn = useMemo(() => {
if (!range) return null
for (let index = range.first; index < items.length; index++) {
const item = items[index]!
const turn = item.type === 'row' ? item.row.turn : item.turn
if (turn !== null && turn !== undefined) return turn
}
return null
}, [items, range])
const drawer = splitWidth > 0 && splitWidth < DRAWER_BELOW
const maxDetailWidth = Math.max(320, splitWidth - LEDGER_MIN_WIDTH)
const closeDetail = useCallback(() => setSelectedId(null), [])
let body: React.ReactNode
if (state.status === 'loading' && state.version === 0) {
body = <LoadingState label={t('trajectory.loading')} variant="inline" size="md" className="flex-1" />
} else if (state.status === 'error' && state.version === 0) {
body = (
<div className="flex flex-1 items-center justify-center p-8">
<ErrorState title={t('trajectory.loadFailed')} detail={state.error ?? undefined} onRetry={() => void reload()} retryLabel={t('common.retry')} />
</div>
)
} else if (!ordered.length) {
body = <EmptyState title={t('trajectory.empty.title')} description={t('trajectory.empty.description')} className="flex-1" />
} else if (!items.length && terms.length) {
body = (
<EmptyState
title={t('trajectory.search.noResults')}
description={t(hasOlder ? 'trajectory.search.noResultsPartial' : 'trajectory.search.noResultsHint')}
action={hasOlder ? { label: t('trajectory.table.loadOlder'), onClick: () => void loadOlder() } : undefined}
className="flex-1"
/>
)
} else {
body = (
<TrajectoryTable
items={items}
selectedId={selectedId}
collapsedTurns={collapsedTurns}
terms={terms}
running={running && !agentId}
onSelect={setSelectedId}
onToggleTurn={handleToggleTurn}
onEscape={closeDetail}
scrollRequest={scrollRequest}
onRangeChange={setRange}
header={hasOlder ? (
<button
type="button"
disabled={state.loadingOlder}
onClick={() => void handleLoadOlder()}
className="rounded-[var(--radius-sm)] px-2 text-xs text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)] disabled:opacity-60"
>
{state.loadingOlder ? t('common.loading') : t(terms.length ? 'trajectory.table.searchLoaded' : 'trajectory.table.loadOlder')}
</button>
) : null}
/>
)
}
const currentStats = currentTurn !== null ? turnStats.get(currentTurn) : undefined
return (
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
data-testid="trajectory-view"
onKeyDown={(event) => {
if (event.key === 'Escape' && selectedId) {
event.stopPropagation()
closeDetail()
}
}}
>
<TrajectoryToolbar
durationMode={durationMode}
onDurationModeChange={setDurationMode}
turnsCollapsed={collapsedTurns.size > 0}
onTurnsCollapsedChange={(collapse) => setCollapsedTurns(collapse ? new Set(listTurns(ordered)) : new Set())}
toolsHidden={toolsHidden}
onToolsHiddenChange={setToolsHidden}
query={query}
onQueryChange={setQuery}
matchCount={terms.length ? items.filter((item) => item.type === 'row').length : null}
totals={totals}
totalsPartial={hasOlder}
agentLabel={agent?.label ?? null}
onExitAgent={() => {
const parentSelection = agent?.parentSelectedId ?? null
setAgentStack([])
setSelectedId(null)
if (parentSelection) setPendingRestore(parentSelection)
}}
/>
{ordered.length > 0 && <TrajectoryMinimap model={minimap} viewport={viewport} rowsById={rowsById} onSelect={reveal} />}
{currentTurn !== null && currentStats && !terms.length && (
<div className="flex h-7 shrink-0 items-center gap-2 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 text-[11px] text-[var(--color-text-secondary)]" data-testid="trajectory-turn-bar">
<button
type="button"
onClick={() => handleToggleTurn(currentTurn)}
aria-expanded={!collapsedTurns.has(currentTurn)}
className="inline-flex items-center gap-1 rounded-[var(--radius-sm)] px-1 font-medium text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
>
{collapsedTurns.has(currentTurn) ? <ChevronRight size={12} aria-hidden /> : <ChevronDown size={12} aria-hidden />}
{turnLabel(currentTurn, t)}
</button>
<span>{t('trajectory.table.toolCount', { count: currentStats.tools })}</span>
{currentStats.errors > 0 && <span className="text-[var(--color-error)]">{t('trajectory.table.errorCount', { count: currentStats.errors })}</span>}
{currentStats.durationMs > 0 && <span>{t('trajectory.detail.approx', { value: formatDurationMs(currentStats.durationMs) })}</span>}
</div>
)}
<div ref={splitRef} className="relative flex min-h-0 flex-1">
<div ref={tableWrapRef} className="flex min-h-0 min-w-0 flex-1 flex-col">{body}</div>
{selectedRow && (
<TrajectoryDetailPanel
sessionId={sessionId}
agentId={agentId}
row={selectedRow}
width={detailWidth}
maxWidth={maxDetailWidth}
mode={drawer ? 'drawer' : 'side'}
allowRawRequest={!agentId}
running={running && !agentId}
onWidthChange={setDetailWidth}
onClose={closeDetail}
onLocateInChat={agentId ? null : (row) => void handleLocateInChat(row)}
onOpenAgent={(id, label) => {
setAgentStack((previous) => [...previous, { agentId: id, label, parentSelectedId: selectedId }])
setSelectedId(null)
}}
onSelectRow={(rowId) => { reveal(rowId) }}
/>
)}
</div>
</div>
)
}
@@ -0,0 +1,25 @@
import { MessagesSquare, ListTree } from 'lucide-react'
import { useTranslation } from '../../i18n'
import { SegmentedControl } from '@/components/ui/SegmentedControl'
import { useTrajectoryViewStore, type SessionViewMode } from '../../stores/trajectoryViewStore'
/** The 对话 / 轨迹 switch in the session header. */
export function TrajectoryViewSwitch({ sessionId }: { sessionId: string }) {
const t = useTranslation()
const mode = useTrajectoryViewStore((state) => state.modes[sessionId] ?? 'chat')
const setMode = useTrajectoryViewStore((state) => state.setMode)
return (
<SegmentedControl<SessionViewMode>
as="tablist"
size="sm"
layout="auto"
label={t('trajectory.switch.label')}
value={mode}
onChange={(next) => setMode(sessionId, next)}
items={[
{ value: 'chat', label: t('trajectory.switch.chat'), icon: <MessagesSquare size={13} aria-hidden /> },
{ value: 'trajectory', label: t('trajectory.switch.trajectory'), icon: <ListTree size={13} aria-hidden /> },
]}
/>
)
}
@@ -0,0 +1,64 @@
import type { ReactNode } from 'react'
import { useTranslation } from '../../../i18n'
import { CopyButton } from '@/components/ui/CopyButton'
import { CodeViewer } from '../../chat/CodeViewer'
import { MarkdownRenderer } from '../../markdown/MarkdownRenderer'
import { formatTraceJson } from '../../../lib/trace/formatters'
/** Past this size, syntax highlighting and markdown parsing cost more than they help. */
const RICH_RENDER_LIMIT = 120_000
const COPY_CLASS = 'shrink-0 whitespace-nowrap rounded-[var(--radius-sm)] border border-[var(--color-border)] px-2 py-0.5 text-[11px] text-[var(--color-text-tertiary)] transition-colors hover:text-[var(--color-text-primary)]'
export function DetailSection({ title, actions, children }: { title: string; actions?: ReactNode; children: ReactNode }) {
return (
<section className="flex flex-col gap-1.5">
<div className="flex items-center justify-between gap-2">
<h3 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">{title}</h3>
{actions}
</div>
{children}
</section>
)
}
export function CopyAction({ text }: { text: string }) {
const t = useTranslation()
return <CopyButton text={text} label={t('trajectory.detail.copy')} copiedLabel={t('common.copied')} className={COPY_CLASS} />
}
/** Grows with its text; the detail panel is the one scroll container. */
export function PlainText({ text }: { text: string }) {
return (
<pre className={`whitespace-pre-wrap break-words rounded-[var(--radius-md)] bg-[var(--color-surface-container-low)] px-3 py-2 font-mono text-[12px] leading-[1.65] text-[var(--color-text-secondary)]`}>
{text}
</pre>
)
}
export function MarkdownText({ text }: { text: string }) {
if (text.length > RICH_RENDER_LIMIT) return <PlainText text={text} />
return <MarkdownRenderer content={text} variant="compact" cache={false} />
}
export function JsonBlock({ value, maxLines = 400 }: { value: unknown; maxLines?: number }) {
const code = formatTraceJson(value)
if (code.length > RICH_RENDER_LIMIT) return <PlainText text={code} />
// The detail panel scrolls on its own; a capped inner box would leave it half empty.
return <CodeViewer code={code} language="json" maxLines={maxLines} showLineNumbers wrapLongLines unboundedHeight />
}
export type MetaItem = { label: string; value: ReactNode }
export function MetaList({ items }: { items: readonly MetaItem[] }) {
return (
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-5 gap-y-1.5 text-[12.5px]">
{items.map((item) => (
<div key={item.label} className="contents">
<dt className="text-[var(--color-text-tertiary)]">{item.label}</dt>
<dd className="min-w-0 break-words text-[var(--color-text-primary)]">{item.value}</dd>
</div>
))}
</dl>
)
}
@@ -83,7 +83,7 @@ function TextBlock({ text }: { text: string }) {
}
return (
<div className="relative">
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-md)] bg-[var(--color-surface)] px-3 py-2 font-mono text-[12px] leading-[1.7] text-[var(--color-text-secondary)]">
<pre className="whitespace-pre-wrap break-words rounded-[var(--radius-md)] bg-[var(--color-surface)] px-3 py-2 font-mono text-[12px] leading-[1.7] text-[var(--color-text-secondary)]">
{text}
</pre>
<CopyButton
@@ -109,7 +109,7 @@ function ThinkingBlock({ thinking }: { thinking: string }) {
{t('trace.detail.thinking')} · {t('trace.detail.chars', { count: thinking.length })}
</button>
{open ? (
<pre className="mt-2 max-h-[300px] overflow-y-auto whitespace-pre-wrap break-words text-[12.5px] italic leading-[1.7] text-[var(--color-text-tertiary)]">
<pre className="mt-2 whitespace-pre-wrap break-words text-[12.5px] italic leading-[1.7] text-[var(--color-text-tertiary)]">
{thinking}
</pre>
) : null}
@@ -126,7 +126,7 @@ function ToolUseBlock({ id, name, input }: { id?: string; name: string; input: u
{id ? <span className="truncate font-mono text-[11.5px] font-normal text-[var(--color-text-tertiary)]">{id}</span> : null}
</div>
<div className="mt-2">
<CodeViewer code={safeJson(input)} language="json" maxLines={24} showLineNumbers />
<CodeViewer code={safeJson(input)} language="json" maxLines={24} showLineNumbers wrapLongLines unboundedHeight />
</div>
</div>
)
@@ -148,7 +148,7 @@ function ToolResultBlock({ toolUseId, content, isError }: { toolUseId?: string;
<div className="mt-2">
{text !== null
? <TextResult text={text} />
: <CodeViewer code={safeJson(content)} language="json" maxLines={24} showLineNumbers />}
: <CodeViewer code={safeJson(content)} language="json" maxLines={24} showLineNumbers wrapLongLines unboundedHeight />}
</div>
</div>
)
@@ -157,7 +157,7 @@ function ToolResultBlock({ toolUseId, content, isError }: { toolUseId?: string;
function TextResult({ text }: { text: string }) {
if (!text.trim()) return null
return (
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words rounded-[var(--radius-md)] bg-[var(--color-surface)] px-3 py-2 font-mono text-[12px] leading-[1.7] text-[var(--color-text-secondary)]">
<pre className="whitespace-pre-wrap break-words rounded-[var(--radius-md)] bg-[var(--color-surface)] px-3 py-2 font-mono text-[12px] leading-[1.7] text-[var(--color-text-secondary)]">
{text}
</pre>
)
@@ -1,8 +1,7 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from '../../../i18n'
import type { TraceBodySnapshot, TraceCallRecord } from '../../../types/trace'
import type { TraceSpan } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/traceViewModel'
import { formatTraceJson } from '../../../lib/trace/formatters'
import { fetchTraceCallDetail } from '../../../lib/trace/callCache'
import { parseTraceRequestBody, parseTraceResponseBody } from '../../../lib/trace/requestParse'
import { splitRequestMessages, type LocatedInjection } from '../../../lib/trace/semanticTimeline'
@@ -14,25 +13,34 @@ import { CopyButton } from '@/components/ui/CopyButton'
import { EmptyState } from '@/components/ui/EmptyState'
import { Spinner } from '@/components/ui/Spinner'
import { Section } from './Section'
import { RequestSummary } from './RequestSummary'
import { MessageBlocks } from './MessageBlocks'
const MESSAGE_FOLD_THRESHOLD = 20
const MESSAGE_HEAD_COUNT = 2
const MESSAGE_TAIL_COUNT = 6
export function LlmCallDetail({
/**
* The captured HTTP exchange for one model call: response, the context the
* harness assembled, the resent conversation, system prompt, tools, params
* and raw bodies. `call` is the trimmed overview record; the full record is
* fetched once the call has finished.
*/
export function RawRequestDetail({
sessionId,
span,
call,
revisionKey,
at,
}: {
sessionId: string
span: TraceSpan
call: TraceCallRecord | null
revisionKey?: string
/** Record locator for calls outside the capture's indexed window. */
at?: string
}) {
const t = useTranslation()
const call = span.call
const callId = call?.id ?? null
const isTerminal = span.status !== 'pending'
const isTerminal = call?.status !== 'pending'
const [detail, setDetail] = useState<TraceCallRecord | null>(null)
const [fetchFailed, setFetchFailed] = useState(false)
const fetchKeyRef = useRef<string | null>(null)
@@ -46,7 +54,7 @@ export function LlmCallDetail({
fetchKeyRef.current = key
// Keep the record on screen while a newer copy loads. Clearing it first
// drops the reader back to the truncated snapshot and resets their scroll.
void fetchTraceCallDetail(sessionId, callId, revisionKey).then((full) => {
void fetchTraceCallDetail(sessionId, callId, revisionKey, at).then((full) => {
if (fetchKeyRef.current !== key) return
if (full) {
setDetail(full)
@@ -55,7 +63,7 @@ export function LlmCallDetail({
setFetchFailed(true)
}
})
}, [sessionId, callId, isTerminal, revisionKey])
}, [sessionId, callId, isTerminal, revisionKey, at])
const effectiveCall = detail && detail.id === callId ? detail : call
const parsed = useMemo(() => {
@@ -104,6 +112,8 @@ export function LlmCallDetail({
<NoticeBar text={t('trace.detail.legacyTruncated')} />
) : null}
<RequestSummary call={effectiveCall} response={parsed.response} />
<Section sectionKey="llm.response" title={t('trace.section.response')} defaultOpen>
<ResponseContent
call={effectiveCall}
@@ -148,7 +158,7 @@ export function LlmCallDetail({
/>
}
>
<pre className="max-h-[400px] overflow-y-auto whitespace-pre-wrap break-words text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
<pre className="whitespace-pre-wrap break-words text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{parsed.request.system}
</pre>
</Section>
@@ -327,7 +337,7 @@ function ContextInjectionList({ injections }: { injections: LocatedInjection[] }
</span>
</button>
{open ? (
<pre className="max-h-[320px] overflow-y-auto whitespace-pre-wrap break-words border-t border-[var(--color-border)] px-2.5 py-2 text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
<pre className="whitespace-pre-wrap break-words border-t border-[var(--color-border)] px-2.5 py-2 text-[12.5px] leading-[1.7] text-[var(--color-text-secondary)]">
{injection.text}
</pre>
) : null}
@@ -366,7 +376,7 @@ function ToolDefinitions({ tools }: { tools: Array<{ name: string; description?:
{active.description ? (
<p className="mb-2 text-[12.5px] leading-[1.6] text-[var(--color-text-secondary)]">{active.description}</p>
) : null}
<CodeViewer code={formatTraceJson(active.schema ?? null)} language="json" maxLines={24} showLineNumbers />
<CodeViewer code={formatTraceJson(active.schema ?? null)} language="json" maxLines={24} showLineNumbers wrapLongLines unboundedHeight />
</div>
) : null}
</div>
@@ -421,7 +431,7 @@ function RawBody({ title, body, maxLines }: { title: string; body: TraceBodySnap
</span>
</div>
{code ? (
<CodeViewer code={code} language={body.contentType === 'json' ? 'json' : 'text'} maxLines={maxLines} showLineNumbers={body.contentType === 'json'} />
<CodeViewer code={code} language={body.contentType === 'json' ? 'json' : 'text'} maxLines={maxLines} showLineNumbers={body.contentType === 'json'} wrapLongLines unboundedHeight />
) : (
<EmptyState description={t('trace.noData')} variant="dashed" size="sm" />
)}
@@ -433,14 +443,14 @@ function RawHeaders({ title, headers }: { title: string; headers: Record<string,
return (
<div>
<div className="mb-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-[var(--color-text-tertiary)]">{title}</div>
<CodeViewer code={formatTraceJson(headers)} language="json" maxLines={20} showLineNumbers />
<CodeViewer code={formatTraceJson(headers)} language="json" maxLines={20} showLineNumbers wrapLongLines unboundedHeight />
</div>
)
}
function NoticeBar({ text }: { text: string }) {
return (
<div className="mx-6 mt-4 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3.5 py-2 text-[12.5px] text-[var(--color-on-warning-container)]">
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3.5 py-2 text-[12.5px] text-[var(--color-on-warning-container)]">
{text}
</div>
)
@@ -0,0 +1,139 @@
import '@testing-library/jest-dom'
import { fireEvent, render, screen, within } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { TraceCallRecord } from '../../../types/trace'
import type { TraceCallsNear, TrajectoryRow } from '../../../types/trajectory'
const getTraceCallsNear = vi.fn<(sessionId: string, from: string, to: string) => Promise<TraceCallsNear>>()
const getTraceCall = vi.fn<(sessionId: string, callId: string, at?: string) => Promise<{ call: TraceCallRecord }>>()
vi.mock('../../../api/trajectory', () => ({
trajectoryApi: {
getTraceCallsNear: (sessionId: string, from: string, to: string) => getTraceCallsNear(sessionId, from, to),
},
}))
vi.mock('../../../api/sessions', () => ({
sessionsApi: {
getTraceCall: (sessionId: string, callId: string, at?: string) => getTraceCall(sessionId, callId, at),
},
}))
import { RawRequestTab } from './RawRequestTab'
import { clearTraceCallCache } from '../../../lib/trace/callCache'
import { resetTraceSectionState } from './Section'
import { useSettingsStore } from '../../../stores/settingsStore'
const at = (seconds: number) => new Date(Date.UTC(2026, 9, 4, 10, 0, 0) + seconds * 1000).toISOString()
const ROW: TrajectoryRow = {
id: 'a:msg_1',
kind: 'assistant',
turn: 1,
ts: at(5),
endTs: at(8),
startTs: at(1),
preview: 'Checking',
loc: [[0, 10]],
model: 'claude-x',
}
const REQUEST = {
model: 'claude-x',
system: [{ type: 'text', text: 'You are the harness system prompt.' }],
tools: [{ name: 'Bash', description: 'Run commands', input_schema: { type: 'object' } }],
messages: [
{ role: 'user', content: [{ type: 'text', text: '<system-reminder>\nSkills available: pdf\n</system-reminder>' }, { type: 'text', text: 'Build the console' }] },
],
max_tokens: 1024,
}
function call(id: string, start: number, end: number, extra: Partial<TraceCallRecord> = {}): TraceCallRecord {
return {
id,
sessionId: 's1',
source: 'anthropic',
model: 'claude-x',
status: 'ok',
startedAt: at(start),
completedAt: at(end),
request: { method: 'POST', url: 'https://api/v1/messages', headers: {}, body: { contentType: 'json', bytes: 10, sha256: 'x', preview: '', truncated: false } },
...extra,
}
}
describe('RawRequestTab', () => {
const setTraceCaptureEnabled = vi.fn(async () => {})
beforeEach(() => {
getTraceCallsNear.mockReset()
getTraceCall.mockReset()
setTraceCaptureEnabled.mockClear()
clearTraceCallCache()
resetTraceSectionState()
useSettingsStore.setState({ locale: 'en', traceCapture: { enabled: true, storageDir: '/tmp' }, setTraceCaptureEnabled })
})
it('shows the captured request and response of the closest call', async () => {
getTraceCallsNear.mockResolvedValue({ captured: true, revisionToken: 'rev-1', calls: [call('early', 0.5, 2), call('match', 1, 8)], locs: { match: '9000-12000' } })
getTraceCall.mockResolvedValue({
call: call('match', 1, 8, {
request: {
method: 'POST',
url: 'https://api/v1/messages',
headers: { 'content-type': 'application/json' },
body: { contentType: 'json', bytes: 100, sha256: 'x', preview: JSON.stringify(REQUEST), truncated: false },
semantic: { version: 1, request: REQUEST },
},
response: {
status: 200,
headers: {},
body: { contentType: 'json', bytes: 50, sha256: 'y', preview: JSON.stringify({ role: 'assistant', content: [{ type: 'text', text: 'Response from the model' }], stop_reason: 'end_turn' }), truncated: false },
},
}),
})
render(<RawRequestTab sessionId="s1" row={ROW} />)
expect(await screen.findByText(/Closest captured call by time/)).toBeInTheDocument()
// The record locator reaches calls beyond the capture's indexed window.
expect(getTraceCall).toHaveBeenCalledWith('s1', 'match', '9000-12000')
expect(await screen.findByText('Response from the model')).toBeInTheDocument()
// Where the call went and with which model sit at the top.
expect(screen.getByTestId('trajectory-request-endpoint')).toHaveTextContent(/POST\s*https:\/\/api\/v1\/messages/)
expect(screen.getByTestId('trajectory-request-model')).toHaveTextContent('claude-x')
expect(screen.getByTestId('trajectory-request-summary')).toHaveTextContent('HTTP 200')
expect(screen.getByText('end_turn')).toBeInTheDocument()
// The harness-injected reminder is split out of the conversation.
expect(screen.getByText('Injected context')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /System prompt/ }))
expect(await screen.findByText('You are the harness system prompt.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: /Tools/ }))
const tools = screen.getByRole('button', { name: /Tools/ }).closest('section') ?? document.body
expect(within(tools as HTMLElement).getByRole('button', { name: 'Bash' })).toBeInTheDocument()
})
it('says the response was never captured and offers the switch right here', async () => {
useSettingsStore.setState({ traceCapture: { enabled: false, storageDir: '/tmp' } })
getTraceCallsNear.mockResolvedValue({ captured: false, calls: [] })
render(<RawRequestTab sessionId="s1" row={ROW} />)
expect(await screen.findByText(/recording was off when this response ran/)).toBeInTheDocument()
fireEvent.click(screen.getByRole('switch', { name: /Record raw requests/ }))
expect(setTraceCaptureEnabled).toHaveBeenCalledWith(true)
expect(getTraceCall).not.toHaveBeenCalled()
})
it('distinguishes a capture with no matching call', async () => {
getTraceCallsNear.mockResolvedValue({ captured: true, calls: [call('later', 30, 40)] })
render(<RawRequestTab sessionId="s1" row={ROW} />)
expect(await screen.findByText(/No captured call matches this response/)).toBeInTheDocument()
// Capture is on, so there is nothing to switch.
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('reports a failed capture lookup instead of spinning', async () => {
getTraceCallsNear.mockRejectedValue(new Error('capture unavailable'))
render(<RawRequestTab sessionId="s1" row={ROW} />)
expect(await screen.findByText(/capture unavailable/)).toBeInTheDocument()
})
})
@@ -0,0 +1,77 @@
import { useEffect, useState } from 'react'
import { useTranslation } from '../../../i18n'
import { trajectoryApi } from '../../../api/trajectory'
import { EmptyState } from '@/components/ui/EmptyState'
import { Spinner } from '@/components/ui/Spinner'
import { Switch } from '@/components/ui/Switch'
import { findTraceCallForRow, type TraceCallLookup } from '../../../lib/trajectory/findTraceCall'
import { useSettingsStore } from '../../../stores/settingsStore'
import type { TrajectoryRow } from '../../../types/trajectory'
import { TraceSectionStateProvider } from './Section'
import { RawRequestDetail } from './RawRequestDetail'
type LookupState = { status: 'loading' } | TraceCallLookup | { status: 'error'; error: string }
/**
* The captured HTTP exchange behind an assistant row, found by time because
* the capture and the transcript are written by different writers. The
* capture switch lives here too, so turning it on never means a trip to
* Settings.
*/
export function RawRequestTab({ sessionId, row }: { sessionId: string; row: TrajectoryRow }) {
const t = useTranslation()
const captureEnabled = useSettingsStore((state) => state.traceCapture?.enabled === true)
const setCaptureEnabled = useSettingsStore((state) => state.setTraceCaptureEnabled)
const [state, setState] = useState<LookupState>({ status: 'loading' })
// Live appends re-create the row object; only a change to its timing can change the match.
const matchKey = `${row.id}:${row.startTs ?? ''}:${row.ts}:${row.endTs ?? ''}`
useEffect(() => {
const controller = new AbortController()
setState({ status: 'loading' })
findTraceCallForRow(row, (from, to) => trajectoryApi.getTraceCallsNear(sessionId, from, to, { signal: controller.signal })).then(
(lookup) => { if (!controller.signal.aborted) setState(lookup) },
(error: unknown) => {
if (!controller.signal.aborted) setState({ status: 'error', error: error instanceof Error ? error.message : String(error) })
},
)
return () => controller.abort()
}, [matchKey, sessionId])
const captureSwitch = (
<div className="rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2.5">
<Switch
checked={captureEnabled}
onChange={(enabled) => void setCaptureEnabled(enabled)}
label={t('trajectory.request.captureToggle')}
description={t('trajectory.request.captureHint')}
size="sm"
/>
</div>
)
if (state.status === 'loading') return <div className="flex justify-center py-8"><Spinner size={16} /></div>
if (state.status === 'error') {
return <EmptyState description={t('trajectory.detail.loadFailed', { error: state.error })} variant="dashed" size="sm" />
}
if (state.status === 'uncaptured' || state.status === 'unmatched') {
return (
<div className="flex flex-col gap-3">
<EmptyState
description={t(state.status === 'uncaptured' ? 'trajectory.request.uncaptured' : 'trajectory.request.unmatched')}
variant="dashed"
size="sm"
/>
{!captureEnabled && captureSwitch}
</div>
)
}
return (
<div className="flex flex-col gap-3">
<p className="text-[12px] text-[var(--color-text-tertiary)]">{t('trajectory.request.closest')}</p>
<TraceSectionStateProvider scopeId={`trajectory:${sessionId}:${state.call.id}`}>
<RawRequestDetail sessionId={sessionId} call={state.call} revisionKey={state.revisionKey} at={state.at} />
</TraceSectionStateProvider>
</div>
)
}
@@ -0,0 +1,89 @@
import { useTranslation } from '../../../i18n'
import { Badge } from '@/components/ui/Badge'
import { formatClockTime, formatDurationMs } from '../../../lib/trace/formatters'
import type { ParsedResponse } from '../../../lib/trace/requestParse'
import { summarizeTraceCall } from '../../../lib/trace/requestSummary'
import type { TraceCallRecord } from '../../../types/trace'
import { CopyAction, MetaList, type MetaItem } from './DetailBlocks'
/**
* Where the call went and with which model, at the top of the raw request:
* the endpoint, the provider, the model id asked for, the one actually sent
* upstream after mapping, and the one the provider says answered.
*/
export function RequestSummary({ call, response }: { call: TraceCallRecord; response: ParsedResponse | null }) {
const t = useTranslation()
const summary = summarizeTraceCall(call, response)
const mono = 'font-mono text-[12px] break-all'
const items: MetaItem[] = []
if (summary.url) {
items.push({
label: t('trajectory.request.endpoint'),
value: (
<span className="flex items-start gap-2">
<span className={mono} data-testid="trajectory-request-endpoint">
{summary.method ? <span className="mr-1.5 font-semibold">{summary.method}</span> : null}
{summary.url}
</span>
<CopyAction text={summary.url} />
</span>
),
})
}
if (summary.providerName || summary.providerFormat) {
items.push({
label: t('trajectory.request.provider'),
value: (
<span className="flex flex-wrap items-center gap-1.5">
{summary.providerName && <span>{summary.providerName}</span>}
{summary.providerFormat && <Badge tone="neutral" size="xs" pill={false} mono>{summary.providerFormat}</Badge>}
</span>
),
})
}
items.push({
label: t('trajectory.request.route'),
value: t(summary.route === 'proxy' ? 'trajectory.request.routeProxy' : 'trajectory.request.routeDirect'),
})
if (summary.requestedModel) {
items.push({ label: t('trajectory.request.model'), value: <span className={mono} data-testid="trajectory-request-model">{summary.requestedModel}</span> })
}
if (summary.upstreamModel) {
items.push({ label: t('trajectory.request.upstreamModel'), value: <span className={mono}>{summary.upstreamModel}</span> })
}
if (summary.respondedModel) {
items.push({ label: t('trajectory.request.respondedModel'), value: <span className={mono}>{summary.respondedModel}</span> })
}
const outcome = [
summary.status !== undefined ? `HTTP ${summary.status}` : null,
summary.durationMs !== undefined ? formatDurationMs(summary.durationMs) : null,
formatClockTime(summary.startedAt),
].filter(Boolean).join(' · ')
items.push({
label: t('trajectory.request.status'),
value: (
<span className={summary.status !== undefined && summary.status >= 400 ? 'text-[var(--color-error)]' : undefined}>{outcome}</span>
),
})
if (summary.requestId) {
items.push({
label: t('trajectory.request.requestId'),
value: (
<span className="flex items-start gap-2">
<span className={mono}>{summary.requestId}</span>
<CopyAction text={summary.requestId} />
</span>
),
})
}
if (summary.querySource) {
items.push({ label: t('trajectory.request.purpose'), value: <span className={mono}>{summary.querySource}</span> })
}
return (
<section className="mb-4 rounded-[var(--radius-md)] border border-[var(--color-border)] px-3 py-2.5" data-testid="trajectory-request-summary">
<MetaList items={items} />
</section>
)
}
@@ -0,0 +1,214 @@
import { useMemo, useState } from 'react'
import { useTranslation } from '../../../i18n'
import { EmptyState } from '@/components/ui/EmptyState'
import { Spinner } from '@/components/ui/Spinner'
import { cx } from '@/lib/cx'
import { diffLines, diffToolCatalogs, type ToolCatalogEntry } from '../../../lib/trajectory/lineDiff'
import type { TrajectorySnapshotBlob, TrajectorySnapshotRef } from '../../../types/trajectory'
import { useSnapshotBlob } from '../useTrajectoryDetail'
import { CopyAction, DetailSection, JsonBlock, PlainText } from './DetailBlocks'
export type SnapshotTab = 'prompt' | 'tools' | 'context' | 'diff'
function asTools(blob: TrajectorySnapshotBlob | null): ToolCatalogEntry[] {
return Array.isArray(blob?.json) ? (blob!.json as ToolCatalogEntry[]).filter((tool) => tool && typeof tool.name === 'string') : []
}
function contextText(blob: TrajectorySnapshotBlob | null): string {
if (!blob) return ''
if (typeof blob.text === 'string') return blob.text
if (blob.json && typeof blob.json === 'object') {
return Object.entries(blob.json as Record<string, unknown>)
.map(([key, value]) => `# ${key}\n${typeof value === 'string' ? value : JSON.stringify(value, null, 2)}`)
.join('\n\n')
}
return ''
}
function Loading() {
return <div className="flex justify-center py-8"><Spinner size={16} /></div>
}
export function snapshotTabs(ref: TrajectorySnapshotRef): SnapshotTab[] {
if (ref.change === 'context') return ref.prevCtx ? ['context', 'diff'] : ['context']
const hasPrevious = Boolean(ref.prevSys || ref.prevTools)
return hasPrevious ? ['prompt', 'tools', 'diff'] : ['prompt', 'tools']
}
export function SnapshotDetail({ sessionId, snapshot, tab }: { sessionId: string; snapshot: TrajectorySnapshotRef; tab: SnapshotTab }) {
const t = useTranslation()
const isContext = snapshot.change === 'context'
const sys = useSnapshotBlob(sessionId, tab === 'prompt' || (tab === 'diff' && !isContext) ? snapshot.sys : undefined)
const tools = useSnapshotBlob(sessionId, tab === 'tools' || (tab === 'diff' && !isContext) ? snapshot.tools : undefined)
const ctx = useSnapshotBlob(sessionId, isContext ? snapshot.ctx : undefined)
const prevSys = useSnapshotBlob(sessionId, tab === 'diff' && !isContext && snapshot.prevSys !== snapshot.sys ? snapshot.prevSys : undefined)
const prevTools = useSnapshotBlob(sessionId, tab === 'diff' && !isContext && snapshot.prevTools !== snapshot.tools ? snapshot.prevTools : undefined)
const prevCtx = useSnapshotBlob(sessionId, tab === 'diff' && isContext ? snapshot.prevCtx : undefined)
const failed = [sys, tools, ctx, prevSys, prevTools, prevCtx].find((item) => item.status === 'error')
if (failed) return <EmptyState description={t('trajectory.detail.loadFailed', { error: failed.error ?? '' })} variant="dashed" size="sm" />
if (tab === 'prompt') {
if (sys.status !== 'ready') return <Loading />
const text = sys.value?.text ?? ''
return (
<DetailSection title={t('trajectory.detail.systemPrompt', { count: text.length.toLocaleString() })} actions={<CopyAction text={text} />}>
{sys.value?.truncated && <TruncatedNotice />}
<PlainText text={text} />
</DetailSection>
)
}
if (tab === 'tools') {
if (tools.status !== 'ready') return <Loading />
return <ToolCatalog tools={asTools(tools.value)} truncated={Boolean(tools.value?.truncated)} />
}
if (tab === 'context') {
if (ctx.status !== 'ready') return <Loading />
const text = contextText(ctx.value)
return (
<DetailSection title={t('trajectory.system.userContext')} actions={<CopyAction text={text} />}>
<PlainText text={text} />
</DetailSection>
)
}
// diff
if (isContext) {
if (ctx.status !== 'ready' || prevCtx.status !== 'ready') return <Loading />
return <TextDiff before={contextText(prevCtx.value)} after={contextText(ctx.value)} />
}
const sysChanged = Boolean(snapshot.prevSys && snapshot.prevSys !== snapshot.sys)
const toolsChanged = Boolean(snapshot.prevTools && snapshot.prevTools !== snapshot.tools)
if ((sysChanged && (sys.status !== 'ready' || prevSys.status !== 'ready')) || (toolsChanged && (tools.status !== 'ready' || prevTools.status !== 'ready'))) {
return <Loading />
}
return (
<div className="flex flex-col gap-5">
{toolsChanged && <ToolCatalogDiff before={asTools(prevTools.value)} after={asTools(tools.value)} />}
{sysChanged && (
<DetailSection title={t('trajectory.detail.systemPromptDiff')}>
<TextDiff before={prevSys.value?.text ?? ''} after={sys.value?.text ?? ''} />
</DetailSection>
)}
{!sysChanged && !toolsChanged && <EmptyState description={t('trajectory.detail.noChanges')} variant="dashed" size="sm" />}
</div>
)
}
function TruncatedNotice() {
const t = useTranslation()
return (
<div className="rounded-[var(--radius-md)] border border-[var(--color-warning)] bg-[var(--color-warning-container)] px-3 py-1.5 text-[12px] text-[var(--color-on-warning-container)]">
{t('trajectory.detail.truncated')}
</div>
)
}
function ToolCatalog({ tools, truncated }: { tools: ToolCatalogEntry[]; truncated: boolean }) {
const t = useTranslation()
const [open, setOpen] = useState<string | null>(null)
return (
<DetailSection title={t('trajectory.table.toolCount', { count: tools.length })}>
{truncated && <TruncatedNotice />}
<ul className="flex flex-col divide-y divide-[var(--color-border)] rounded-[var(--radius-md)] border border-[var(--color-border)]">
{tools.map((tool) => {
const expanded = open === tool.name
return (
<li key={tool.name}>
<button
type="button"
aria-expanded={expanded}
onClick={() => setOpen(expanded ? null : tool.name)}
className="flex w-full min-w-0 items-baseline gap-2 px-3 py-1.5 text-left hover:bg-[var(--color-surface-hover)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--color-border-focus)]"
>
<span className="shrink-0 font-mono text-[12px] font-medium text-[var(--color-text-primary)]">{tool.name}</span>
<span className="min-w-0 truncate text-[12px] text-[var(--color-text-tertiary)]">{tool.description?.split('\n')[0]}</span>
</button>
{expanded && (
<div className="flex flex-col gap-2 px-3 pb-3">
{tool.description && <PlainText text={tool.description} />}
<JsonBlock value={tool.input_schema ?? null} maxLines={60} />
</div>
)}
</li>
)
})}
</ul>
</DetailSection>
)
}
function ToolCatalogDiff({ before, after }: { before: ToolCatalogEntry[]; after: ToolCatalogEntry[] }) {
const t = useTranslation()
const diff = useMemo(() => diffToolCatalogs(before, after), [after, before])
const groups = [
{ key: 'added', label: t('trajectory.detail.toolsAdded'), names: diff.added, tone: 'text-[var(--color-success)]' },
{ key: 'removed', label: t('trajectory.detail.toolsRemoved'), names: diff.removed, tone: 'text-[var(--color-error)]' },
{ key: 'changed', label: t('trajectory.detail.toolsChanged'), names: diff.changed, tone: 'text-[var(--color-text-primary)]' },
].filter((group) => group.names.length)
return (
<DetailSection title={t('trajectory.detail.toolsDiff')}>
{groups.length ? groups.map((group) => (
<div key={group.key} className="flex flex-wrap items-baseline gap-1.5 text-[12px]">
<span className="text-[var(--color-text-tertiary)]">{group.label}</span>
{group.names.map((name) => <span key={name} className={cx('font-mono', group.tone)}>{name}</span>)}
</div>
)) : <span className="text-[12px] text-[var(--color-text-tertiary)]">{t('trajectory.detail.noChanges')}</span>}
</DetailSection>
)
}
/** Unchanged runs longer than this collapse to a marker so the change stays on screen. */
const CONTEXT_LINES = 3
function TextDiff({ before, after }: { before: string; after: string }) {
const t = useTranslation()
const lines = useMemo(() => diffLines(before, after), [after, before])
const rendered: Array<{ key: string; type: 'same' | 'add' | 'remove' | 'gap'; text: string }> = []
let index = 0
while (index < lines.length) {
if (lines[index]!.type !== 'same') {
rendered.push({ key: `l${index}`, ...lines[index]! })
index++
continue
}
let end = index
while (end < lines.length && lines[end]!.type === 'same') end++
const run = end - index
if (run > CONTEXT_LINES * 2 + 1) {
const keepHead = index === 0 ? 0 : CONTEXT_LINES
const keepTail = end === lines.length ? 0 : CONTEXT_LINES
for (let i = index; i < index + keepHead; i++) rendered.push({ key: `l${i}`, ...lines[i]! })
rendered.push({ key: `g${index}`, type: 'gap', text: t('trajectory.detail.unchangedLines', { count: run - keepHead - keepTail }) })
for (let i = end - keepTail; i < end; i++) rendered.push({ key: `l${i}`, ...lines[i]! })
} else {
for (let i = index; i < end; i++) rendered.push({ key: `l${i}`, ...lines[i]! })
}
index = end
}
if (!rendered.some((line) => line.type === 'add' || line.type === 'remove')) {
return <span className="text-[12px] text-[var(--color-text-tertiary)]">{t('trajectory.detail.noChanges')}</span>
}
return (
<div className="overflow-x-auto rounded-[var(--radius-md)] border border-[var(--color-border)] font-mono text-[12px] leading-[1.6]" data-testid="trajectory-diff">
{rendered.map((line) => (
<div
key={line.key}
data-diff={line.type}
className={cx(
'whitespace-pre-wrap break-words px-3',
line.type === 'add' && 'bg-[var(--color-success-container)] text-[var(--color-on-success-container)]',
line.type === 'remove' && 'bg-[var(--color-error-container)] text-[var(--color-on-error-container)]',
line.type === 'same' && 'text-[var(--color-text-secondary)]',
line.type === 'gap' && 'bg-[var(--color-surface-container-low)] py-0.5 text-center text-[11px] text-[var(--color-text-tertiary)]',
)}
>
{line.type === 'add' ? '+ ' : line.type === 'remove' ? '- ' : line.type === 'same' ? ' ' : ''}
{line.text}
</div>
))}
</div>
)
}
@@ -0,0 +1,159 @@
import type { TranslationKey } from '../../i18n/locales/en'
import type { TrajectoryRow, TrajectoryRowKind } from '../../types/trajectory'
import type { Tone } from '@/components/ui/Badge'
type Translate = (key: TranslationKey, params?: Record<string, string | number>) => string
export const KIND_LABEL_KEYS: Record<TrajectoryRowKind, TranslationKey> = {
system: 'trajectory.kind.system',
user: 'trajectory.kind.user',
context: 'trajectory.kind.context',
assistant: 'trajectory.kind.assistant',
tool: 'trajectory.kind.tool',
compact: 'trajectory.kind.compact',
}
/**
* The prompt is the anchor of every turn, so it takes the accent; responses
* are the most numerous rows and stay neutral. `info` is avoided: in the paper
* themes it is the same terracotta as `brand`.
*/
export const KIND_TONES: Record<TrajectoryRowKind, Tone> = {
system: 'neutral',
user: 'brand',
context: 'success',
assistant: 'neutral',
tool: 'warning',
compact: 'neutral',
}
/**
* Minimap span fill per kind — the same hue as the kind's badge, so a color
* means one thing everywhere in the view. Failures are drawn separately.
*/
export const KIND_FILL_CLASSES: Record<TrajectoryRowKind, string> = {
system: 'bg-[var(--color-text-tertiary)]',
user: 'bg-[var(--color-brand)]',
context: 'bg-[var(--color-success)]',
assistant: 'bg-[var(--color-text-secondary)]',
tool: 'bg-[var(--color-warning)]',
compact: 'bg-[var(--color-text-tertiary)]',
}
/** Harness-authored rows (system prompt, compaction) are outlined to set them apart from the conversation. */
export const KIND_VARIANTS: Record<TrajectoryRowKind, 'soft' | 'outline'> = {
system: 'outline',
user: 'soft',
context: 'soft',
assistant: 'soft',
tool: 'soft',
compact: 'outline',
}
/** A readable name for a subagent launched by an Agent tool call. */
export function agentLabelFromInput(inputPreview: string | undefined, fallback: string): string {
if (!inputPreview) return fallback
for (const key of ['description', 'subagent_type', 'name']) {
const match = new RegExp(`"${key}":"((?:[^"\\\\]|\\\\.)*)"`).exec(inputPreview)
if (match?.[1]) return match[1].replace(/\\"/g, '"')
}
return fallback
}
/** Readable names for the attachment / injection types the harness sends. */
const CONTEXT_LABEL_KEYS: Record<string, TranslationKey> = {
skill: 'trajectory.context.skill',
skill_listing: 'trajectory.context.skillListing',
invoked_skills: 'trajectory.context.invokedSkills',
dynamic_skill: 'trajectory.context.dynamicSkill',
skill_discovery: 'trajectory.context.skillDiscovery',
meta: 'trajectory.context.meta',
summary: 'trajectory.context.summary',
user_context: 'trajectory.context.userContext',
todo_reminder: 'trajectory.context.todoReminder',
task_reminder: 'trajectory.context.taskReminder',
nested_memory: 'trajectory.context.memory',
relevant_memories: 'trajectory.context.memory',
current_session_memory: 'trajectory.context.memory',
plan_mode: 'trajectory.context.planMode',
plan_mode_reentry: 'trajectory.context.planMode',
plan_mode_exit: 'trajectory.context.planMode',
plan_file_reference: 'trajectory.context.planMode',
auto_mode: 'trajectory.context.autoMode',
auto_mode_exit: 'trajectory.context.autoMode',
date_change: 'trajectory.context.dateChange',
deferred_tools_delta: 'trajectory.context.toolsDelta',
agent_listing_delta: 'trajectory.context.agentsDelta',
mcp_instructions_delta: 'trajectory.context.mcpInstructions',
mcp_resource: 'trajectory.context.mcpResource',
critical_system_reminder: 'trajectory.context.reminder',
diagnostics: 'trajectory.context.diagnostics',
file: 'trajectory.context.file',
already_read_file: 'trajectory.context.file',
compact_file_reference: 'trajectory.context.file',
pdf_reference: 'trajectory.context.file',
edited_text_file: 'trajectory.context.fileChange',
edited_image_file: 'trajectory.context.fileChange',
directory: 'trajectory.context.file',
selected_lines_in_ide: 'trajectory.context.ide',
opened_file_in_ide: 'trajectory.context.ide',
queued_command: 'trajectory.context.queued',
task_status: 'trajectory.context.taskStatus',
token_usage: 'trajectory.context.budget',
budget_usd: 'trajectory.context.budget',
output_token_usage: 'trajectory.context.budget',
teammate_mailbox: 'trajectory.context.team',
team_context: 'trajectory.context.team',
api_error: 'trajectory.context.apiError',
task_notification: 'trajectory.context.taskNotification',
command_output: 'trajectory.context.commandOutput',
interrupted: 'trajectory.context.interrupted',
api_retry: 'trajectory.context.apiRetry',
local_command: 'trajectory.context.localCommand',
hook_summary: 'trajectory.context.hook',
stream_retry: 'trajectory.context.streamRetry',
informational: 'trajectory.context.informational',
away_summary: 'trajectory.context.awaySummary',
scheduled_task: 'trajectory.context.scheduledTask',
deferred_tools_record: 'trajectory.context.toolsDelta',
environment: 'trajectory.context.environment',
instructions: 'trajectory.context.instructions',
session_context: 'trajectory.context.sessionContext',
permission_retry: 'trajectory.context.permissionRetry',
memory_saved: 'trajectory.context.memorySaved',
agents_killed: 'trajectory.context.agentsKilled',
model_fallback: 'trajectory.context.modelFallback',
}
/** Sub label for user rows that did not come from typing a prompt. */
export function userLabel(row: TrajectoryRow, t: Translate): string | null {
switch (row.label) {
case 'queued': return t('trajectory.user.queued')
case 'command': return t('trajectory.user.command')
case 'session_message': return t('trajectory.user.sessionMessage')
default: return null
}
}
export function contextLabel(row: TrajectoryRow, t: Translate): string | null {
const type = row.label ?? row.attachmentType
if (!type) return null
if (type.startsWith('hook_') || type === 'async_hook_response') return t('trajectory.context.hook')
const key = CONTEXT_LABEL_KEYS[type]
return key ? t(key) : type
}
export function systemRowTitle(row: TrajectoryRow, t: Translate): string {
switch (row.snapshot?.change) {
case 'system': return t('trajectory.system.updated')
case 'tools': return t('trajectory.system.toolsUpdated')
case 'system+tools': return t('trajectory.system.bothUpdated')
case 'context': return t('trajectory.system.userContext')
default: return t('trajectory.system.initial')
}
}
export function turnLabel(turn: number | null, t: Translate): string {
if (turn === null) return ''
return turn === 0 ? t('trajectory.turn.before') : t('trajectory.turn.n', { n: turn })
}
@@ -0,0 +1,202 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { ApiError } from '../../api/client'
import { trajectoryApi } from '../../api/trajectory'
import { mergeRows } from '../../lib/trajectory/mergeRows'
import type { TrajectoryPage, TrajectoryRow } from '../../types/trajectory'
export type TrajectoryDataState = {
status: 'loading' | 'ready' | 'error'
error: string | null
rows: ReadonlyMap<string, TrajectoryRow>
snapshots: readonly TrajectoryRow[]
olderCursor: string | null
tailToken: string | null
historyComplete: boolean
sourceMissing: boolean
loadingOlder: boolean
/** Bumps on every applied change, a cheap memo key for derived views. */
version: number
}
const EMPTY_STATE: TrajectoryDataState = {
status: 'loading',
error: null,
rows: new Map(),
snapshots: [],
olderCursor: null,
tailToken: null,
historyComplete: false,
sourceMissing: false,
loadingOlder: false,
version: 0,
}
/** Kept across unmounts so flipping between chat and trajectory is instant. */
const CACHE_LIMIT = 12
const cache = new Map<string, TrajectoryDataState>()
function remember(key: string, state: TrajectoryDataState) {
cache.delete(key)
cache.set(key, state)
while (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!)
}
export function clearTrajectoryDataCache() {
cache.clear()
}
export const APPEND_THROTTLE_MS = 1500
export const RUNNING_POLL_MS = 4000
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
function isAbort(error: unknown): boolean {
return error instanceof DOMException && error.name === 'AbortError'
}
function applyPage(state: TrajectoryDataState, page: TrajectoryPage, mode: 'tail' | 'older' | 'append'): TrajectoryDataState {
return {
...state,
status: 'ready',
error: null,
rows: mode === 'tail' ? mergeRows(new Map(), page.rows) : mergeRows(state.rows, page.rows),
snapshots: page.snapshots ?? state.snapshots,
olderCursor: mode === 'append' ? state.olderCursor : page.olderCursor,
tailToken: mode === 'older' ? state.tailToken : page.tailToken,
historyComplete: mode === 'append' ? state.historyComplete : page.historyComplete,
sourceMissing: Boolean(page.sourceMissing),
loadingOlder: mode === 'older' ? false : state.loadingOlder,
version: state.version + 1,
}
}
/**
* Loads one trajectory scope (the main transcript or one subagent) and keeps
* it current while visible. Nothing is fetched while `visible` is false, so a
* mounted-but-hidden view costs no requests.
*/
export function useTrajectoryData(options: {
sessionId: string
agentId?: string
visible: boolean
running: boolean
/** Changes whenever the chat sees new activity; drives throttled appends. */
activityKey: string | number
}) {
const { sessionId, agentId, visible, running, activityKey } = options
const key = `${sessionId}::${agentId ?? 'main'}`
const [state, setState] = useState<TrajectoryDataState>(() => cache.get(key) ?? EMPTY_STATE)
const stateRef = useRef(state)
const keyRef = useRef(key)
const inflight = useRef<AbortController | null>(null)
const commit = useCallback((forKey: string, update: (previous: TrajectoryDataState) => TrajectoryDataState) => {
if (keyRef.current !== forKey) return
const next = update(stateRef.current)
stateRef.current = next
remember(forKey, next)
setState(next)
}, [])
useEffect(() => {
keyRef.current = key
const cached = cache.get(key) ?? EMPTY_STATE
stateRef.current = cached
setState(cached)
return () => {
inflight.current?.abort()
inflight.current = null
}
}, [key])
const loadTail = useCallback(async () => {
const forKey = keyRef.current
inflight.current?.abort()
const controller = new AbortController()
inflight.current = controller
try {
const page = await trajectoryApi.getPage(sessionId, { agentId }, { signal: controller.signal })
commit(forKey, (previous) => applyPage(previous, page, 'tail'))
} catch (error) {
if (isAbort(error)) return
commit(forKey, (previous) => ({ ...previous, status: previous.version ? previous.status : 'error', error: errorMessage(error) }))
} finally {
if (inflight.current === controller) inflight.current = null
}
}, [agentId, commit, sessionId])
const append = useCallback(async () => {
const forKey = keyRef.current
const token = stateRef.current.tailToken
if (!token) return loadTail()
if (inflight.current) return
const controller = new AbortController()
inflight.current = controller
try {
const page = await trajectoryApi.getPage(sessionId, { after: token, agentId }, { signal: controller.signal })
commit(forKey, (previous) => applyPage(previous, page, 'append'))
} catch (error) {
if (isAbort(error)) return
if (inflight.current === controller) inflight.current = null
// The transcript was rewritten (rewind, compaction rewrite): start over.
if (error instanceof ApiError && error.status === 409) return loadTail()
commit(forKey, (previous) => ({ ...previous, error: errorMessage(error) }))
} finally {
if (inflight.current === controller) inflight.current = null
}
}, [agentId, commit, loadTail, sessionId])
const loadOlder = useCallback(async () => {
const forKey = keyRef.current
const cursor = stateRef.current.olderCursor
if (!cursor || stateRef.current.loadingOlder) return false
commit(forKey, (previous) => ({ ...previous, loadingOlder: true }))
try {
const page = await trajectoryApi.getPage(sessionId, { cursor, agentId })
commit(forKey, (previous) => applyPage(previous, page, 'older'))
return true
} catch (error) {
if (error instanceof ApiError && error.status === 409) {
commit(forKey, (previous) => ({ ...previous, loadingOlder: false }))
await loadTail()
return false
}
commit(forKey, (previous) => ({ ...previous, loadingOlder: false, error: errorMessage(error) }))
return false
}
}, [agentId, commit, loadTail, sessionId])
// First load, and a refresh of cached data when the view becomes visible.
useEffect(() => {
if (!visible) return
if (stateRef.current.version === 0) void loadTail()
else void append()
}, [visible, key, loadTail, append])
// Activity-driven appends, trailing-throttled.
const lastActivity = useRef(activityKey)
useEffect(() => {
if (!visible || lastActivity.current === activityKey) return
lastActivity.current = activityKey
const timer = setTimeout(() => { void append() }, APPEND_THROTTLE_MS)
return () => clearTimeout(timer)
}, [activityKey, append, visible])
// Fallback poll while a turn runs, plus one final read when it ends.
const wasRunning = useRef(running)
useEffect(() => {
if (!visible) return
if (wasRunning.current && !running) void append()
wasRunning.current = running
if (!running) return
const timer = setInterval(() => {
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return
void append()
}, RUNNING_POLL_MS)
return () => clearInterval(timer)
}, [append, running, visible])
return { state, loadOlder, reload: loadTail }
}
@@ -0,0 +1,81 @@
import { useEffect, useState } from 'react'
import { trajectoryApi } from '../../api/trajectory'
import type { TrajectoryRow, TrajectoryRowDetail, TrajectorySnapshotBlob } from '../../types/trajectory'
type Loadable<T> = { status: 'idle' | 'loading' | 'ready' | 'error'; value: T | null; error: string | null }
const IDLE = { status: 'idle', value: null, error: null } as const
function lru<T>(limit: number) {
const map = new Map<string, T>()
return {
get(key: string) {
const value = map.get(key)
if (value !== undefined) {
map.delete(key)
map.set(key, value)
}
return value
},
set(key: string, value: T) {
map.delete(key)
map.set(key, value)
while (map.size > limit) map.delete(map.keys().next().value!)
},
clear: () => map.clear(),
}
}
const rowCache = lru<TrajectoryRowDetail>(48)
const snapshotCache = lru<TrajectorySnapshotBlob>(24)
export function clearTrajectoryDetailCache() {
rowCache.clear()
snapshotCache.clear()
}
function useCachedFetch<T>(key: string | null, cache: ReturnType<typeof lru<T>>, load: (signal: AbortSignal) => Promise<T>): Loadable<T> {
const [state, setState] = useState<Loadable<T>>(() => {
const cached = key ? cache.get(key) : undefined
return cached ? { status: 'ready', value: cached, error: null } : key ? { status: 'loading', value: null, error: null } : IDLE
})
useEffect(() => {
if (!key) {
setState(IDLE)
return
}
const cached = cache.get(key)
if (cached) {
setState({ status: 'ready', value: cached, error: null })
return
}
const controller = new AbortController()
setState({ status: 'loading', value: null, error: null })
load(controller.signal).then(
(value) => {
cache.set(key, value)
if (!controller.signal.aborted) setState({ status: 'ready', value, error: null })
},
(error: unknown) => {
if (controller.signal.aborted) return
setState({ status: 'error', value: null, error: error instanceof Error ? error.message : String(error) })
},
)
return () => controller.abort()
// `load` is recreated per render; the key fully identifies the request.
}, [key])
return state
}
/** Full records behind one row. The locator set is part of the key, so a row that grew a result refetches. */
export function useRowDetail(sessionId: string, agentId: string | undefined, row: TrajectoryRow | null) {
const key = row && row.loc.length
? `${sessionId}:${agentId ?? 'main'}:${row.id}:${row.loc.map(([start, end]) => `${start}-${end}`).join(',')}`
: null
return useCachedFetch(key, rowCache, (signal) => trajectoryApi.getRow(sessionId, row!.id, row!.loc, agentId, { signal }))
}
export function useSnapshotBlob(sessionId: string, hash: string | undefined) {
const key = hash ? `${sessionId}:${hash}` : null
return useCachedFetch(key, snapshotCache, (signal) => trajectoryApi.getSnapshot(sessionId, hash!, { signal }))
}
+173 -103
View File
@@ -986,7 +986,6 @@ export const en = {
'settings.tab.providers': 'Model Settings',
'settings.tab.permissions': 'Permissions',
'settings.tab.activity': 'Token usage',
'settings.tab.trace': 'Trace',
'settings.tab.general': 'General',
'settings.tab.h5Access': 'H5 Access',
'settings.tab.terminal': 'Terminal',
@@ -2447,8 +2446,8 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'settings.general.traceTitle': 'Agent trace',
'settings.general.traceDescription': 'Collect local model request traces for stuck sessions, failed calls, and unexpected waits.',
'settings.general.traceEnabled': 'Collect agent traces',
'settings.general.traceHintOn': 'New sessions write compact requests, responses, and status events to the local cc-haha traces directory.',
'settings.general.traceHintOff': 'No new traces will be written. Existing records remain available in the trace list.',
'settings.general.traceHintOn': 'New sessions write compact requests, responses, and status events to the local cc-haha traces directory. Open an assistant row in the session\'s Trajectory view and switch to Raw request to inspect them.',
'settings.general.traceHintOff': 'No new requests will be recorded. Existing records stay available under Raw request in each session\'s Trajectory view.',
'settings.general.chatSendBehaviorTitle': 'Message Sending',
'settings.general.chatSendBehaviorDescription': 'Choose how the desktop chat composer submits messages.',
'settings.general.chatSendBehaviorEnter': 'Enter sends',
@@ -3558,51 +3557,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'session.timeMinutes': '{n}m ago',
'session.timeHours': '{n}h ago',
'session.timeDays': '{n}d ago',
'trace.open': 'Trace',
'trace.title': 'Model trace',
'trace.loadFailed': 'Failed to load trace',
'trace.apiCalls': 'API calls',
'trace.failedCalls': 'Failed',
'trace.duration': 'Duration',
'trace.elapsed': 'Elapsed',
'trace.completed': 'Completed',
'trace.wallTime': 'Wall time',
'trace.modelTime': 'Model time',
'trace.toolTime': 'Tool time',
'trace.models': 'Models',
'trace.request': 'Request',
'trace.response': 'Response',
'trace.status': 'Status',
'trace.live': 'Live',
'trace.updatedAt': 'Updated',
'trace.copySessionId': 'Copy session ID',
'trace.refresh': 'Refresh trace',
'trace.openWindow': 'Open in separate window',
'trace.backToList': 'Back to list',
'trace.delete': 'Delete trace',
'trace.noResponse': 'No response body captured',
'trace.emptyTitle': 'No trace calls yet',
'trace.emptyBody': 'This session has no captured model calls. New desktop sessions record compact request and response previews automatically.',
'trace.tokens': 'Input + output',
'trace.inputOutputTokens': 'Input → output',
'trace.cacheTokens': 'Cache read → write',
'trace.snapshotEmpty': 'Trace snapshot is empty.',
'trace.errors': 'Errors',
'trace.focus': 'Focus',
'trace.searchSpans': 'Search spans',
'trace.noMatchingSpans': 'No matching spans',
'trace.filter.aria': 'Filter spans',
'trace.filter.all': 'All',
'trace.filter.llm': 'LLM',
'trace.filter.tools': 'Tools',
'trace.filter.errors': 'Errors',
'trace.sidechain': 'sub',
'trace.turnLabel': 'Turn {index}',
'trace.waitingForResult': 'Waiting for result',
'trace.started': 'Started',
'trace.provider': 'Provider',
'trace.model': 'Model',
'trace.childSpans': 'Child spans',
'trace.requestBody': 'Request body',
'trace.requestHeaders': 'Request headers',
'trace.responseBody': 'Response body',
@@ -3610,36 +3565,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trace.toolResult': 'Tool result',
'trace.truncatedShort': 'truncated',
'trace.noData': 'No data',
'trace.llmCalls': 'LLM calls',
'trace.toolCalls': 'Tool calls',
'trace.status.ok': 'ok',
'trace.status.error': 'error',
'trace.status.pending': 'pending',
'trace.message.user': 'User message',
'trace.message.assistant': 'Assistant message',
'trace.message.redactedReasoning': 'Reasoning withheld by provider',
'trace.message.system': 'System message',
'trace.message.toolRequest': 'Assistant tool request',
'trace.message.toolResult': 'Tool result',
'trace.toolError': 'Tool error',
'trace.modelCall': 'Model call',
'trace.sessionActivity': 'Session activity',
'trace.diagnosis.modelError': 'Model call failed',
'trace.diagnosis.toolError': 'Tool execution failed',
'trace.diagnosis.eventError': 'Trace event failed',
'trace.diagnosis.pendingModel': 'Waiting for model response',
'trace.diagnosis.pendingTool': 'Waiting for tool result',
'trace.diagnosis.waitingForAgent': 'User message has not entered an agent step',
'trace.diagnosis.empty': 'No diagnostic events',
'trace.diagnosis.healthy': 'No blocking point detected',
'trace.event.apiCallStarted': 'API call started',
'trace.event.apiCallCompleted': 'API call completed',
'trace.event.apiCallFailed': 'API call failed',
'trace.event.apiCallAborted': 'API call aborted',
'trace.event.responseCaptureFailed': 'Response capture failed',
'trace.event.upstreamFetchStarted': 'Upstream fetch started',
'trace.event.upstreamFetchCompleted': 'Upstream fetch completed',
'trace.event.upstreamFetchFailed': 'Upstream fetch failed',
'trace.section.response': 'Response',
'trace.section.messages': 'Messages',
'trace.section.systemPrompt': 'System prompt',
@@ -3650,12 +3576,6 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trace.section.tools': 'Tools',
'trace.section.parameters': 'Parameters',
'trace.section.raw': 'Raw',
'trace.section.input': 'Input',
'trace.section.result': 'Result',
'trace.section.meta': 'Meta',
'trace.section.content': 'Content',
'trace.section.event': 'Event',
'trace.section.metadata': 'Metadata',
'trace.detail.stopReason': 'Stop reason',
'trace.detail.streaming': 'Waiting for the model response...',
'trace.detail.aborted': 'The request was aborted before the response completed (timeout or cancellation).',
@@ -3665,27 +3585,6 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'trace.detail.earlierMessages': 'Show {count} earlier messages',
'trace.detail.chars': '{count} chars',
'trace.detail.thinking': 'thinking',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': 'Phase',
'trace.detail.severity': 'Severity',
'trace.detail.message': 'Message',
'trace.tree.aria': 'Trace timeline',
'trace.tree.toggleTurn': 'Toggle turn',
'trace.list.eyebrow': 'Agent trace',
'trace.list.title': 'Trace list',
'trace.list.collecting': 'Collecting',
'trace.list.paused': 'Paused',
'trace.list.settings': 'Trace settings',
'trace.list.sessions': 'Sessions',
'trace.list.searchPlaceholder': 'Search title, session ID, or project path',
'trace.list.emptyTitle': 'No trace records yet',
'trace.list.emptyBody': 'When collection is enabled, new desktop sessions write model-call traces to the local traces directory.',
'trace.list.loadFailed': 'Failed to load trace list',
'trace.list.loadedCount': 'Showing {shown} of {total}',
'trace.list.loadMore': 'Load more',
'trace.list.deleteConfirmTitle': 'Delete trace session',
'trace.list.deleteConfirmBody': 'Delete trace data for "{title}"? Chat history is not deleted.',
'trace.list.deleteFailed': 'Failed to delete trace session',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'Local server failed to start',
@@ -3960,6 +3859,7 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'mermaid.fitDiagram': 'Fit diagram',
'codeViewer.collapse': 'Collapse',
'codeViewer.showMoreLines': 'Show {count} more lines',
'common.copy': 'Copy',
// ─── Browser Panel ──────────────────────────────────────
'browser.back': 'Back',
@@ -4129,6 +4029,176 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le
'voice.composer.error.failed': 'Transcription failed. Try again.',
'voice.composer.error.noSpeech': 'No speech was recognized.',
'voice.composer.error.tooShort': 'The recording was too short.',
'trajectory.switch.label': "Session view",
'trajectory.switch.chat': "Chat",
'trajectory.switch.trajectory': "Trajectory",
'trajectory.viewInTrajectory': "View in trajectory",
'trajectory.loading': "Loading trajectory…",
'trajectory.loadFailed': "Couldn't load the trajectory",
'trajectory.empty.title': "No trajectory yet",
'trajectory.empty.description': "Send a message and every model call, tool call and injected context will show up here.",
'trajectory.kind.system': "System",
'trajectory.kind.user': "User",
'trajectory.kind.context': "Context",
'trajectory.kind.assistant': "Assistant",
'trajectory.kind.tool': "Tool",
'trajectory.kind.compact': "Compact",
'trajectory.turn.n': "Turn {n}",
'trajectory.turn.before': "Start",
'trajectory.system.initial': "Initial system prompt",
'trajectory.system.updated': "System prompt updated",
'trajectory.system.toolsUpdated': "Tools updated",
'trajectory.system.bothUpdated': "System prompt and tools updated",
'trajectory.system.userContext': "User context (CLAUDE.md, date)",
'trajectory.system.chars': "{count} chars",
'trajectory.context.skill': "Skill",
'trajectory.context.skillListing': "Skill list",
'trajectory.context.invokedSkills': "Invoked skills",
'trajectory.context.dynamicSkill': "Dynamic skill",
'trajectory.context.skillDiscovery': "Skill discovery",
'trajectory.context.meta': "Injected",
'trajectory.context.summary': "Compact summary",
'trajectory.context.userContext': "User context",
'trajectory.context.todoReminder': "Todo reminder",
'trajectory.context.taskReminder': "Task reminder",
'trajectory.context.memory': "Memory",
'trajectory.context.planMode': "Plan mode",
'trajectory.context.autoMode': "Auto mode",
'trajectory.context.dateChange': "Date change",
'trajectory.context.toolsDelta': "Tool changes",
'trajectory.context.agentsDelta': "Agent list",
'trajectory.context.mcpInstructions': "MCP instructions",
'trajectory.context.mcpResource': "MCP resource",
'trajectory.context.reminder': "System reminder",
'trajectory.context.diagnostics': "Diagnostics",
'trajectory.context.file': "File",
'trajectory.context.fileChange': "File change",
'trajectory.context.ide': "IDE",
'trajectory.context.queued': "Queued input",
'trajectory.context.taskStatus': "Task status",
'trajectory.context.budget': "Budget",
'trajectory.context.team': "Team",
'trajectory.context.apiError': "API error",
'trajectory.context.hook': "Hook",
'trajectory.context.taskNotification': "Task notification",
'trajectory.context.commandOutput': "Command output",
'trajectory.context.interrupted': "Interrupted",
'trajectory.user.queued': "Queued",
'trajectory.assistant.toolOnly': "(tool calls only)",
'trajectory.assistant.thinkingOnly': "(thinking only)",
'trajectory.tool.unknown': "Tool",
'trajectory.tool.running': "Running…",
'trajectory.tool.emptyResult': "(empty)",
'trajectory.compact.title': "Context compacted",
'trajectory.status.done': "Done",
'trajectory.status.error': "Failed",
'trajectory.table.label': "Trajectory",
'trajectory.table.folded': "{count} more rows",
'trajectory.table.toolCount': "{count} tools",
'trajectory.table.collapseTurn': "Collapse {turn}",
'trajectory.table.expandTurn': "Expand {turn}",
'trajectory.table.loadOlder': "Load earlier records",
'trajectory.table.searchLoaded': "Searching loaded records only · load earlier",
'trajectory.minimap.input': "Input",
'trajectory.minimap.model': "Model",
'trajectory.minimap.tools': "Tools",
'trajectory.toolbar.duration': "By duration",
'trajectory.toolbar.collapseTurns': "Fold turns",
'trajectory.toolbar.hideTools': "Hide tools",
'trajectory.toolbar.search': "Search trajectory",
'trajectory.toolbar.clearSearch': "Clear search",
'trajectory.toolbar.totals': "{turns} turns · {steps} steps · {tools} tool calls · {tokens} tok",
'trajectory.toolbar.cacheHit': "cache hit {percent}%",
'trajectory.subagent.breadcrumb': "Subagent trajectory",
'trajectory.subagent.main': "Main session",
'trajectory.subagent.view': "View subagent trajectory",
'trajectory.tab.overview': "Overview",
'trajectory.tab.preview': "Preview",
'trajectory.tab.input': "Input",
'trajectory.tab.output': "Result",
'trajectory.tab.raw': "Raw",
'trajectory.tab.request': "Raw request",
'trajectory.tab.prompt': "System prompt",
'trajectory.tab.tools': "Tools",
'trajectory.tab.context': "Content",
'trajectory.tab.diff': "Diff",
'trajectory.detail.label': "Trajectory details",
'trajectory.detail.tabs': "Detail sections",
'trajectory.detail.resize': "Resize details panel",
'trajectory.detail.copy': "Copy",
'trajectory.detail.loadFailed': "Failed to load: {error}",
'trajectory.detail.notLoaded': "Not available in this record",
'trajectory.detail.truncated': "Content is too large; only the beginning is shown.",
'trajectory.detail.empty': "Nothing to show",
'trajectory.detail.time': "Time",
'trajectory.detail.source': "Source",
'trajectory.detail.status': "Status",
'trajectory.detail.model': "Model",
'trajectory.detail.stopReason': "Stop reason",
'trajectory.detail.tokens': "Tokens",
'trajectory.detail.tokenBreakdown': "in {input} · cache read {cacheRead} · cache write {cacheWrite} · out {output}",
'trajectory.detail.duration': "Duration",
'trajectory.detail.approx': "≈ {value}",
'trajectory.detail.issuedBy': "Issued by",
'trajectory.detail.locateInChat': "Locate in chat",
'trajectory.detail.thinking': "Thinking",
'trajectory.detail.resultText': "Result sent to the model",
'trajectory.detail.resultStructured': "Structured result",
'trajectory.detail.systemPrompt': "System prompt · {count} chars",
'trajectory.detail.systemPromptDiff': "System prompt changes",
'trajectory.detail.toolsDiff': "Tool changes",
'trajectory.detail.toolsAdded': "Added",
'trajectory.detail.toolsRemoved': "Removed",
'trajectory.detail.toolsChanged': "Changed",
'trajectory.detail.noChanges': "No changes",
'trajectory.detail.unchangedLines': "{count} unchanged lines",
'trajectory.request.closest': "Closest captured call by time. Requests and the transcript are recorded separately, so the pairing is approximate.",
'trajectory.toolbar.durationHint': "Lay the minimap out by real duration, so slow calls take more width",
'trajectory.toolbar.loadedFrom': "From turn {turn} (loaded)",
'trajectory.toolbar.matches': "{count} matches",
'trajectory.toolbar.totalsHint': "Counts cover the loaded records and exclude subagents' own tokens",
'trajectory.table.errorCount': "{count} failed",
'trajectory.table.usageTitle': "Input tokens including cache ↑ / output tokens ↓",
'trajectory.table.usageShort': "{input}↑ {output}↓",
'trajectory.tool.callEarlier': "(call is in earlier history)",
'trajectory.tool.resultTooLarge': "Result too large to load ({size})",
'trajectory.tool.noResult': "No result",
'trajectory.minimap.label': "Trajectory minimap: input, model and tool lanes",
'trajectory.search.noResults': "No matching records",
'trajectory.search.noResultsHint': "Try another keyword.",
'trajectory.search.noResultsPartial': "Only loaded records were searched. Load earlier records and search again.",
'trajectory.detail.back': "Back to list",
'trajectory.detail.images': "Contains {count} image(s)",
'trajectory.request.captureToggle': "Record raw requests (Agent Trace)",
'trajectory.request.captureHint': "New model calls will save the request actually sent and the response received.",
'trajectory.request.uncaptured': "Raw request recording was off when this response ran.",
'trajectory.request.unmatched': "No captured call matches this response.",
'trajectory.user.command': "Command",
'trajectory.user.sessionMessage': "Session message",
'trajectory.context.apiRetry': "API retry",
'trajectory.context.localCommand': "Local command",
'trajectory.context.streamRetry': "Stream retry",
'trajectory.context.informational': "Notice",
'trajectory.context.awaySummary': "Away summary",
'trajectory.context.scheduledTask': "Scheduled task",
'trajectory.context.environment': "Environment",
'trajectory.context.instructions': "Instructions",
'trajectory.context.sessionContext': "Session context",
'trajectory.context.permissionRetry': "Permission granted",
'trajectory.context.memorySaved': "Memory saved",
'trajectory.context.agentsKilled': "Agents stopped",
'trajectory.context.modelFallback': "Model fallback",
'trajectory.request.endpoint': "Endpoint",
'trajectory.request.provider': "Provider",
'trajectory.request.route': "Route",
'trajectory.request.routeDirect': "Sent directly by the SDK",
'trajectory.request.routeProxy': "Translated by the local proxy, then sent upstream",
'trajectory.request.model': "Model",
'trajectory.request.upstreamModel': "Sent upstream as",
'trajectory.request.respondedModel': "Answered by",
'trajectory.request.status': "Result",
'trajectory.request.requestId': "Request ID",
'trajectory.request.purpose': "Purpose",
} as const
export type TranslationKey = keyof typeof en
+173 -103
View File
@@ -987,7 +987,6 @@ export const jp: Record<TranslationKey, string> = {
'settings.tab.providers': 'モデル設定',
'settings.tab.permissions': '権限',
'settings.tab.activity': 'トークン使用量',
'settings.tab.trace': 'Trace',
'settings.tab.general': '一般',
'settings.tab.h5Access': 'H5 アクセス',
'settings.tab.terminal': 'ターミナル',
@@ -2448,8 +2447,8 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '停止、失敗、予期しない待機を調査するために、ローカルセッションのモデル要求トレースを収集します。',
'settings.general.traceEnabled': 'Agent Trace を収集',
'settings.general.traceHintOn': '新しいセッションは、簡潔な要求、応答、状態イベントをローカルの cc-haha traces ディレクトリに書き込みます。',
'settings.general.traceHintOff': '新しい Trace は書き込まれません。既存の記録は Trace 一覧で引き続き確認できます。',
'settings.general.traceHintOn': '新しいセッションは、簡潔な要求、応答、状態イベントをローカルの cc-haha traces ディレクトリに書き込みます。セッションの「軌跡」ビューでアシスタント行を開き、「生リクエスト」に切り替えると確認できます。',
'settings.general.traceHintOff': '新しいリクエストは記録されません。既存の記録は、各セッションの「軌跡」ビューの「生リクエスト」で引き続き確認できます。',
'settings.general.chatSendBehaviorTitle': 'メッセージの送信',
'settings.general.chatSendBehaviorDescription': 'デスクトップのチャット入力欄でメッセージを送信する方法を選択します。',
'settings.general.chatSendBehaviorEnter': 'Enter で送信',
@@ -3559,51 +3558,7 @@ export const jp: Record<TranslationKey, string> = {
'session.timeMinutes': '{n} 分前',
'session.timeHours': '{n} 時間前',
'session.timeDays': '{n} 日前',
'trace.open': 'Trace',
'trace.title': 'モデル Trace',
'trace.loadFailed': 'Trace の読み込みに失敗しました',
'trace.apiCalls': 'API 呼び出し',
'trace.failedCalls': '失敗',
'trace.duration': '所要時間',
'trace.elapsed': '経過時間',
'trace.completed': '完了',
'trace.wallTime': '実時間',
'trace.modelTime': 'モデル時間',
'trace.toolTime': 'ツール時間',
'trace.models': 'モデル',
'trace.request': 'リクエスト',
'trace.response': 'レスポンス',
'trace.status': 'ステータス',
'trace.live': 'ライブ',
'trace.updatedAt': '更新',
'trace.copySessionId': 'Session ID をコピー',
'trace.refresh': 'Trace を更新',
'trace.openWindow': '別ウィンドウで開く',
'trace.backToList': '一覧に戻る',
'trace.delete': 'Trace を削除',
'trace.noResponse': 'レスポンス本文はキャプチャされていません',
'trace.emptyTitle': 'Trace 呼び出しはまだありません',
'trace.emptyBody': 'このセッションにはキャプチャ済みのモデル呼び出しがありません。新しいデスクトップセッションでは、軽量なリクエストとレスポンスのプレビューを自動記録します。',
'trace.tokens': '入力 + 出力',
'trace.inputOutputTokens': '入力 → 出力',
'trace.cacheTokens': 'キャッシュ読み取り → 書き込み',
'trace.snapshotEmpty': 'Trace スナップショットは空です。',
'trace.errors': 'エラー',
'trace.focus': '注目箇所',
'trace.searchSpans': 'Span を検索',
'trace.noMatchingSpans': '一致する Span はありません',
'trace.filter.aria': 'Span を絞り込み',
'trace.filter.all': 'すべて',
'trace.filter.llm': 'LLM',
'trace.filter.tools': 'ツール',
'trace.filter.errors': 'エラー',
'trace.sidechain': '子',
'trace.turnLabel': 'ターン {index}',
'trace.waitingForResult': '結果待ち',
'trace.started': '開始',
'trace.provider': 'Provider',
'trace.model': 'モデル',
'trace.childSpans': '子 Span',
'trace.requestBody': 'リクエスト本文',
'trace.requestHeaders': 'リクエストヘッダー',
'trace.responseBody': 'レスポンス本文',
@@ -3611,36 +3566,7 @@ export const jp: Record<TranslationKey, string> = {
'trace.toolResult': 'ツール結果',
'trace.truncatedShort': '切り詰め済み',
'trace.noData': 'データなし',
'trace.llmCalls': 'LLM 呼び出し',
'trace.toolCalls': 'ツール呼び出し',
'trace.status.ok': '正常',
'trace.status.error': 'エラー',
'trace.status.pending': '待機中',
'trace.message.user': 'ユーザーメッセージ',
'trace.message.assistant': 'アシスタントメッセージ',
'trace.message.redactedReasoning': '推論内容はプロバイダーにより非開示',
'trace.message.system': 'システムメッセージ',
'trace.message.toolRequest': 'アシスタントのツール要求',
'trace.message.toolResult': 'ツール結果',
'trace.toolError': 'ツールエラー',
'trace.modelCall': 'モデル呼び出し',
'trace.sessionActivity': 'セッションアクティビティ',
'trace.diagnosis.modelError': 'モデル呼び出しに失敗しました',
'trace.diagnosis.toolError': 'ツール実行に失敗しました',
'trace.diagnosis.eventError': 'トレースイベントに失敗しました',
'trace.diagnosis.pendingModel': 'モデル応答を待機中',
'trace.diagnosis.pendingTool': 'ツール結果を待機中',
'trace.diagnosis.waitingForAgent': 'ユーザーメッセージはまだ agent ステップに入っていません',
'trace.diagnosis.empty': '診断イベントがありません',
'trace.diagnosis.healthy': 'ブロック箇所は検出されていません',
'trace.event.apiCallStarted': 'API 呼び出し開始',
'trace.event.apiCallCompleted': 'API 呼び出し完了',
'trace.event.apiCallFailed': 'API 呼び出し失敗',
'trace.event.apiCallAborted': 'API 呼び出し中断',
'trace.event.responseCaptureFailed': 'レスポンスキャプチャ失敗',
'trace.event.upstreamFetchStarted': 'アップストリーム要求開始',
'trace.event.upstreamFetchCompleted': 'アップストリーム要求完了',
'trace.event.upstreamFetchFailed': 'アップストリーム要求失敗',
'trace.section.response': 'レスポンス',
'trace.section.messages': 'メッセージ',
'trace.section.systemPrompt': 'システムプロンプト',
@@ -3651,12 +3577,6 @@ export const jp: Record<TranslationKey, string> = {
'trace.section.tools': 'ツール',
'trace.section.parameters': 'パラメータ',
'trace.section.raw': '生データ',
'trace.section.input': '入力',
'trace.section.result': '結果',
'trace.section.meta': 'メタ情報',
'trace.section.content': '内容',
'trace.section.event': 'イベント',
'trace.section.metadata': 'メタデータ',
'trace.detail.stopReason': '停止理由',
'trace.detail.streaming': 'モデルの応答を待機中...',
'trace.detail.aborted': '応答が完了する前にリクエストが中断されました(タイムアウトまたはキャンセル)。',
@@ -3666,27 +3586,6 @@ export const jp: Record<TranslationKey, string> = {
'trace.detail.earlierMessages': '以前の {count} 件のメッセージを表示',
'trace.detail.chars': '{count} 文字',
'trace.detail.thinking': '思考',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': 'フェーズ',
'trace.detail.severity': '重要度',
'trace.detail.message': 'メッセージ',
'trace.tree.aria': 'トレースタイムライン',
'trace.tree.toggleTurn': 'ターンを開閉',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 一覧',
'trace.list.collecting': '収集中',
'trace.list.paused': '一時停止',
'trace.list.settings': 'Trace 設定',
'trace.list.sessions': 'セッション',
'trace.list.searchPlaceholder': 'タイトル、Session ID、プロジェクトパスを検索',
'trace.list.emptyTitle': 'Trace 記録はまだありません',
'trace.list.emptyBody': '収集を有効にすると、新しいデスクトップセッションはモデル呼び出しトレースをローカルの traces ディレクトリに書き込みます。',
'trace.list.loadFailed': 'Trace 一覧の読み込みに失敗しました',
'trace.list.loadedCount': '{shown} / {total} を表示中',
'trace.list.loadMore': 'さらに読み込む',
'trace.list.deleteConfirmTitle': 'Trace セッションを削除',
'trace.list.deleteConfirmBody': '「{title}」の Trace データを削除しますか?チャット履歴は削除されません。',
'trace.list.deleteFailed': 'Trace セッションの削除に失敗しました',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': 'ローカルサーバーの起動に失敗しました',
@@ -3961,6 +3860,7 @@ export const jp: Record<TranslationKey, string> = {
'mermaid.fitDiagram': '図を画面に合わせる',
'codeViewer.collapse': '折りたたむ',
'codeViewer.showMoreLines': '残り {count} 行を表示',
'common.copy': 'コピー',
// ─── Browser Panel ──────────────────────────────────────
'browser.back': '戻る',
@@ -4130,4 +4030,174 @@ export const jp: Record<TranslationKey, string> = {
'voice.composer.error.failed': '認識に失敗しました。もう一度お試しください。',
'voice.composer.error.noSpeech': '音声が認識されませんでした。',
'voice.composer.error.tooShort': '録音が短すぎます。',
'trajectory.switch.label': "セッション表示",
'trajectory.switch.chat': "会話",
'trajectory.switch.trajectory': "軌跡",
'trajectory.viewInTrajectory': "軌跡で表示",
'trajectory.loading': "軌跡を読み込み中…",
'trajectory.loadFailed': "軌跡を読み込めませんでした",
'trajectory.empty.title': "まだ軌跡がありません",
'trajectory.empty.description': "メッセージを送ると、モデル呼び出し・ツール呼び出し・注入されたコンテキストがここに表示されます。",
'trajectory.kind.system': "システム",
'trajectory.kind.user': "ユーザー",
'trajectory.kind.context': "コンテキスト",
'trajectory.kind.assistant': "アシスタント",
'trajectory.kind.tool': "ツール",
'trajectory.kind.compact': "圧縮",
'trajectory.turn.n': "ターン {n}",
'trajectory.turn.before': "開始前",
'trajectory.system.initial': "初期システムプロンプト",
'trajectory.system.updated': "システムプロンプトを更新",
'trajectory.system.toolsUpdated': "ツールを更新",
'trajectory.system.bothUpdated': "システムプロンプトとツールを更新",
'trajectory.system.userContext': "ユーザーコンテキスト(CLAUDE.md・日付)",
'trajectory.system.chars': "{count} 文字",
'trajectory.context.skill': "スキル",
'trajectory.context.skillListing': "スキル一覧",
'trajectory.context.invokedSkills': "呼び出したスキル",
'trajectory.context.dynamicSkill': "動的スキル",
'trajectory.context.skillDiscovery': "スキル検出",
'trajectory.context.meta': "注入内容",
'trajectory.context.summary': "圧縮サマリー",
'trajectory.context.userContext': "ユーザーコンテキスト",
'trajectory.context.todoReminder': "Todo リマインダー",
'trajectory.context.taskReminder': "タスクリマインダー",
'trajectory.context.memory': "メモリ",
'trajectory.context.planMode': "プランモード",
'trajectory.context.autoMode': "自動モード",
'trajectory.context.dateChange': "日付の変更",
'trajectory.context.toolsDelta': "ツールの変更",
'trajectory.context.agentsDelta': "エージェント一覧",
'trajectory.context.mcpInstructions': "MCP 指示",
'trajectory.context.mcpResource': "MCP リソース",
'trajectory.context.reminder': "システムリマインダー",
'trajectory.context.diagnostics': "診断",
'trajectory.context.file': "ファイル",
'trajectory.context.fileChange': "ファイル変更",
'trajectory.context.ide': "IDE",
'trajectory.context.queued': "割り込み入力",
'trajectory.context.taskStatus': "タスク状態",
'trajectory.context.budget': "予算",
'trajectory.context.team': "チーム",
'trajectory.context.apiError': "API エラー",
'trajectory.context.hook': "フック",
'trajectory.context.taskNotification': "タスク通知",
'trajectory.context.commandOutput': "コマンド出力",
'trajectory.context.interrupted': "中断",
'trajectory.user.queued': "割り込み",
'trajectory.assistant.toolOnly': "(ツール呼び出しのみ)",
'trajectory.assistant.thinkingOnly': "(思考のみ)",
'trajectory.tool.unknown': "ツール",
'trajectory.tool.running': "実行中…",
'trajectory.tool.emptyResult': "(空)",
'trajectory.compact.title': "コンテキストを圧縮",
'trajectory.status.done': "完了",
'trajectory.status.error': "失敗",
'trajectory.table.label': "軌跡",
'trajectory.table.folded': "{count} 行を折りたたみ中",
'trajectory.table.toolCount': "ツール {count} 個",
'trajectory.table.collapseTurn': "{turn}を折りたたむ",
'trajectory.table.expandTurn': "{turn}を展開",
'trajectory.table.loadOlder': "以前の記録を読み込む",
'trajectory.table.searchLoaded': "読み込み済みのみ検索 · 以前を読み込む",
'trajectory.minimap.input': "入力",
'trajectory.minimap.model': "モデル",
'trajectory.minimap.tools': "ツール",
'trajectory.toolbar.duration': "所要時間",
'trajectory.toolbar.collapseTurns': "ターンを折りたたむ",
'trajectory.toolbar.hideTools': "ツールを隠す",
'trajectory.toolbar.search': "軌跡を検索",
'trajectory.toolbar.clearSearch': "検索をクリア",
'trajectory.toolbar.totals': "{turns} ターン · {steps} ステップ · 呼び出し {tools} 回 · {tokens} tok",
'trajectory.toolbar.cacheHit': "キャッシュヒット {percent}%",
'trajectory.subagent.breadcrumb': "サブエージェントの軌跡",
'trajectory.subagent.main': "メインセッション",
'trajectory.subagent.view': "サブエージェントの軌跡を表示",
'trajectory.tab.overview': "概要",
'trajectory.tab.preview': "プレビュー",
'trajectory.tab.input': "入力",
'trajectory.tab.output': "結果",
'trajectory.tab.raw': "生データ",
'trajectory.tab.request': "生リクエスト",
'trajectory.tab.prompt': "システムプロンプト",
'trajectory.tab.tools': "ツール",
'trajectory.tab.context': "内容",
'trajectory.tab.diff': "差分",
'trajectory.detail.label': "軌跡の詳細",
'trajectory.detail.tabs': "詳細セクション",
'trajectory.detail.resize': "詳細パネルの幅を調整",
'trajectory.detail.copy': "コピー",
'trajectory.detail.loadFailed': "読み込みに失敗しました: {error}",
'trajectory.detail.notLoaded': "この記録には含まれていません",
'trajectory.detail.truncated': "内容が大きすぎるため、先頭のみ表示しています。",
'trajectory.detail.empty': "表示する内容がありません",
'trajectory.detail.time': "時刻",
'trajectory.detail.source': "ソース",
'trajectory.detail.status': "状態",
'trajectory.detail.model': "モデル",
'trajectory.detail.stopReason': "停止理由",
'trajectory.detail.tokens': "トークン",
'trajectory.detail.tokenBreakdown': "入力 {input} · キャッシュ読取 {cacheRead} · キャッシュ書込 {cacheWrite} · 出力 {output}",
'trajectory.detail.duration': "所要時間",
'trajectory.detail.approx': "約 {value}",
'trajectory.detail.issuedBy': "発行元",
'trajectory.detail.locateInChat': "会話で表示",
'trajectory.detail.thinking': "思考",
'trajectory.detail.resultText': "モデルに返した結果",
'trajectory.detail.resultStructured': "構造化結果",
'trajectory.detail.systemPrompt': "システムプロンプト · {count} 文字",
'trajectory.detail.systemPromptDiff': "システムプロンプトの変更",
'trajectory.detail.toolsDiff': "ツールの変更",
'trajectory.detail.toolsAdded': "追加",
'trajectory.detail.toolsRemoved': "削除",
'trajectory.detail.toolsChanged': "変更",
'trajectory.detail.noChanges': "変更なし",
'trajectory.detail.unchangedLines': "変更のない {count} 行",
'trajectory.request.closest': "時刻で一致した最も近いキャプチャ呼び出しです。リクエストと会話記録は別々に保存されるため、対応は近似です。",
'trajectory.toolbar.durationHint': "ミニマップを実際の所要時間で並べ、遅い呼び出しを広く表示",
'trajectory.toolbar.loadedFrom': "ターン {turn} 以降(読み込み済み)",
'trajectory.toolbar.matches': "{count} 件一致",
'trajectory.toolbar.totalsHint': "集計は読み込み済みの記録が対象で、サブエージェント自身のトークンは含みません",
'trajectory.table.errorCount': "{count} 件失敗",
'trajectory.table.usageTitle': "入力トークン(キャッシュ含む)↑ / 出力トークン ↓",
'trajectory.table.usageShort': "{input}↑ {output}↓",
'trajectory.tool.callEarlier': "(呼び出しは以前の履歴にあります)",
'trajectory.tool.resultTooLarge': "結果が大きすぎるため未読み込み({size})",
'trajectory.tool.noResult': "結果なし",
'trajectory.minimap.label': "軌跡ミニマップ:入力・モデル・ツールの3レーン",
'trajectory.search.noResults': "一致する記録がありません",
'trajectory.search.noResultsHint': "別のキーワードを試してください。",
'trajectory.search.noResultsPartial': "読み込み済みの記録のみ検索しました。以前の記録を読み込んでから再検索してください。",
'trajectory.detail.back': "一覧に戻る",
'trajectory.detail.images': "画像 {count} 枚を含む",
'trajectory.request.captureToggle': "生リクエストを記録(Agent Trace)",
'trajectory.request.captureHint': "有効にすると、新しいモデル呼び出しで実際に送ったリクエストと受け取ったレスポンスを保存します。",
'trajectory.request.uncaptured': "この応答の実行時、生リクエストの記録はオフでした。",
'trajectory.request.unmatched': "この応答に対応する呼び出しが見つかりません。",
'trajectory.user.command': "コマンド",
'trajectory.user.sessionMessage': "セッションメッセージ",
'trajectory.context.apiRetry': "API 再試行",
'trajectory.context.localCommand': "ローカルコマンド",
'trajectory.context.streamRetry': "ストリーム再試行",
'trajectory.context.informational': "お知らせ",
'trajectory.context.awaySummary': "不在中の要約",
'trajectory.context.scheduledTask': "スケジュールタスク",
'trajectory.context.environment': "環境情報",
'trajectory.context.instructions': "指示",
'trajectory.context.sessionContext': "セッションコンテキスト",
'trajectory.context.permissionRetry': "権限を許可",
'trajectory.context.memorySaved': "メモリを保存",
'trajectory.context.agentsKilled': "エージェントを停止",
'trajectory.context.modelFallback': "モデルのフォールバック",
'trajectory.request.endpoint': "エンドポイント",
'trajectory.request.provider': "プロバイダー",
'trajectory.request.route': "経路",
'trajectory.request.routeDirect': "SDK から直接送信",
'trajectory.request.routeProxy': "ローカルプロキシでプロトコル変換後に上流へ送信",
'trajectory.request.model': "リクエストのモデル",
'trajectory.request.upstreamModel': "上流に送ったモデル",
'trajectory.request.respondedModel': "応答が示すモデル",
'trajectory.request.status': "結果",
'trajectory.request.requestId': "リクエスト ID",
'trajectory.request.purpose': "呼び出し元",
}
+173 -103
View File
@@ -989,7 +989,6 @@ export const kr: Record<TranslationKey, string> = {
'settings.tab.providers': '모델 설정',
'settings.tab.permissions': '권한',
'settings.tab.activity': '토큰 사용량',
'settings.tab.trace': 'Trace',
'settings.tab.general': '일반',
'settings.tab.h5Access': 'H5 액세스',
'settings.tab.terminal': '터미널',
@@ -2450,8 +2449,8 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '멈춤, 실패, 예상치 못한 대기를 조사하기 위해 로컬 세션의 모델 요청 트레이스를 수집합니다.',
'settings.general.traceEnabled': 'Agent Trace 수집',
'settings.general.traceHintOn': '새 세션은 간결한 요청, 응답, 상태 이벤트를 로컬 cc-haha traces 디렉터리에 기록합니다.',
'settings.general.traceHintOff': '새 Trace는 기록되지 않습니다. 기존 기록은 Trace 목록에서 계속 볼 수 있습니다.',
'settings.general.traceHintOn': '새 세션은 간결한 요청, 응답, 상태 이벤트를 로컬 cc-haha traces 디렉터리에 기록합니다. 세션의 \'궤적\' 보기에서 어시스턴트 행을 열고 \'원본 요청\'으로 전환하면 확인할 수 있습니다.',
'settings.general.traceHintOff': '새 요청은 기록되지 않습니다. 기존 기록은 각 세션의 \'궤적\' 보기에서 \'원본 요청\'으로 계속 볼 수 있습니다.',
'settings.general.chatSendBehaviorTitle': '메시지 전송',
'settings.general.chatSendBehaviorDescription': '데스크톱 채팅 입력창에서 메시지를 전송하는 방식을 선택합니다.',
'settings.general.chatSendBehaviorEnter': 'Enter로 전송',
@@ -3561,51 +3560,7 @@ export const kr: Record<TranslationKey, string> = {
'session.timeMinutes': '{n}분 전',
'session.timeHours': '{n}시간 전',
'session.timeDays': '{n}일 전',
'trace.open': 'Trace',
'trace.title': '모델 Trace',
'trace.loadFailed': 'Trace를 불러오지 못했습니다',
'trace.apiCalls': 'API 호출',
'trace.failedCalls': '실패',
'trace.duration': '소요 시간',
'trace.elapsed': '경과 시간',
'trace.completed': '완료',
'trace.wallTime': '실제 시간',
'trace.modelTime': '모델 시간',
'trace.toolTime': '도구 시간',
'trace.models': '모델',
'trace.request': '요청',
'trace.response': '응답',
'trace.status': '상태',
'trace.live': '실시간',
'trace.updatedAt': '업데이트',
'trace.copySessionId': 'Session ID 복사',
'trace.refresh': 'Trace 새로고침',
'trace.openWindow': '별도 창에서 열기',
'trace.backToList': '목록으로',
'trace.delete': 'Trace 삭제',
'trace.noResponse': '응답 본문이 캡처되지 않았습니다',
'trace.emptyTitle': '아직 Trace 호출이 없습니다',
'trace.emptyBody': '이 세션에는 캡처된 모델 호출이 없습니다. 새 데스크톱 세션은 간단한 요청 및 응답 미리보기를 자동으로 기록합니다.',
'trace.tokens': '입력 + 출력',
'trace.inputOutputTokens': '입력 → 출력',
'trace.cacheTokens': '캐시 읽기 → 쓰기',
'trace.snapshotEmpty': 'Trace 스냅샷이 비어 있습니다.',
'trace.errors': '오류',
'trace.focus': '집중 지점',
'trace.searchSpans': 'Span 검색',
'trace.noMatchingSpans': '일치하는 Span이 없습니다',
'trace.filter.aria': 'Span 필터',
'trace.filter.all': '전체',
'trace.filter.llm': 'LLM',
'trace.filter.tools': '도구',
'trace.filter.errors': '오류',
'trace.sidechain': '하위',
'trace.turnLabel': '{index}번째 턴',
'trace.waitingForResult': '결과 대기 중',
'trace.started': '시작',
'trace.provider': 'Provider',
'trace.model': '모델',
'trace.childSpans': '하위 Span',
'trace.requestBody': '요청 본문',
'trace.requestHeaders': '요청 헤더',
'trace.responseBody': '응답 본문',
@@ -3613,36 +3568,7 @@ export const kr: Record<TranslationKey, string> = {
'trace.toolResult': '도구 결과',
'trace.truncatedShort': '잘림',
'trace.noData': '데이터 없음',
'trace.llmCalls': 'LLM 호출',
'trace.toolCalls': '도구 호출',
'trace.status.ok': '정상',
'trace.status.error': '오류',
'trace.status.pending': '대기 중',
'trace.message.user': '사용자 메시지',
'trace.message.assistant': '어시스턴트 메시지',
'trace.message.redactedReasoning': '제공자가 추론 내용을 비공개 처리',
'trace.message.system': '시스템 메시지',
'trace.message.toolRequest': '어시스턴트 도구 요청',
'trace.message.toolResult': '도구 결과',
'trace.toolError': '도구 오류',
'trace.modelCall': '모델 호출',
'trace.sessionActivity': '세션 활동',
'trace.diagnosis.modelError': '모델 호출 실패',
'trace.diagnosis.toolError': '도구 실행 실패',
'trace.diagnosis.eventError': '트레이스 이벤트 실패',
'trace.diagnosis.pendingModel': '모델 응답 대기 중',
'trace.diagnosis.pendingTool': '도구 결과 대기 중',
'trace.diagnosis.waitingForAgent': '사용자 메시지가 아직 agent 단계에 들어가지 않았습니다',
'trace.diagnosis.empty': '진단 이벤트가 없습니다',
'trace.diagnosis.healthy': '차단 지점이 감지되지 않았습니다',
'trace.event.apiCallStarted': 'API 호출 시작',
'trace.event.apiCallCompleted': 'API 호출 완료',
'trace.event.apiCallFailed': 'API 호출 실패',
'trace.event.apiCallAborted': 'API 호출 중단',
'trace.event.responseCaptureFailed': '응답 캡처 실패',
'trace.event.upstreamFetchStarted': '업스트림 요청 시작',
'trace.event.upstreamFetchCompleted': '업스트림 요청 완료',
'trace.event.upstreamFetchFailed': '업스트림 요청 실패',
'trace.section.response': '응답',
'trace.section.messages': '메시지',
'trace.section.systemPrompt': '시스템 프롬프트',
@@ -3653,12 +3579,6 @@ export const kr: Record<TranslationKey, string> = {
'trace.section.tools': '도구',
'trace.section.parameters': '파라미터',
'trace.section.raw': '원본 데이터',
'trace.section.input': '입력',
'trace.section.result': '결과',
'trace.section.meta': '메타 정보',
'trace.section.content': '내용',
'trace.section.event': '이벤트',
'trace.section.metadata': '메타데이터',
'trace.detail.stopReason': '중지 사유',
'trace.detail.streaming': '모델 응답을 기다리는 중...',
'trace.detail.aborted': '응답이 완료되기 전에 요청이 중단되었습니다(시간 초과 또는 취소).',
@@ -3668,27 +3588,6 @@ export const kr: Record<TranslationKey, string> = {
'trace.detail.earlierMessages': '이전 메시지 {count}개 보기',
'trace.detail.chars': '{count}자',
'trace.detail.thinking': '생각',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': '단계',
'trace.detail.severity': '심각도',
'trace.detail.message': '메시지',
'trace.tree.aria': '트레이스 타임라인',
'trace.tree.toggleTurn': '턴 접기/펼치기',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 목록',
'trace.list.collecting': '수집 중',
'trace.list.paused': '일시 중지됨',
'trace.list.settings': 'Trace 설정',
'trace.list.sessions': '세션',
'trace.list.searchPlaceholder': '제목, Session ID 또는 프로젝트 경로 검색',
'trace.list.emptyTitle': '아직 Trace 기록이 없습니다',
'trace.list.emptyBody': '수집을 켜면 새 데스크톱 세션이 모델 호출 트레이스를 로컬 traces 디렉터리에 기록합니다.',
'trace.list.loadFailed': 'Trace 목록을 불러오지 못했습니다',
'trace.list.loadedCount': '{shown} / {total} 표시 중',
'trace.list.loadMore': '더 불러오기',
'trace.list.deleteConfirmTitle': 'Trace 세션 삭제',
'trace.list.deleteConfirmBody': '"{title}"의 Trace 데이터를 삭제하시겠습니까? 채팅 기록은 삭제되지 않습니다.',
'trace.list.deleteFailed': 'Trace 세션을 삭제하지 못했습니다',
// ─── App Shell ──────────────────────────────────────
'app.serverFailed': '로컬 서버를 시작하지 못했습니다',
@@ -3963,6 +3862,7 @@ export const kr: Record<TranslationKey, string> = {
'mermaid.fitDiagram': '다이어그램을 화면에 맞추기',
'codeViewer.collapse': '접기',
'codeViewer.showMoreLines': '{count}줄 더 보기',
'common.copy': '복사',
// ─── Browser Panel ──────────────────────────────────────
'browser.back': '뒤로',
@@ -4132,4 +4032,174 @@ export const kr: Record<TranslationKey, string> = {
'voice.composer.error.failed': '인식에 실패했습니다. 다시 시도해 주세요.',
'voice.composer.error.noSpeech': '음성이 인식되지 않았습니다.',
'voice.composer.error.tooShort': '녹음이 너무 짧습니다.',
'trajectory.switch.label': "세션 보기",
'trajectory.switch.chat': "대화",
'trajectory.switch.trajectory': "궤적",
'trajectory.viewInTrajectory': "궤적에서 보기",
'trajectory.loading': "궤적 불러오는 중…",
'trajectory.loadFailed': "궤적을 불러오지 못했습니다",
'trajectory.empty.title': "아직 궤적이 없습니다",
'trajectory.empty.description': "메시지를 보내면 모든 모델 호출, 도구 호출, 주입된 컨텍스트가 여기에 표시됩니다.",
'trajectory.kind.system': "시스템",
'trajectory.kind.user': "사용자",
'trajectory.kind.context': "컨텍스트",
'trajectory.kind.assistant': "어시스턴트",
'trajectory.kind.tool': "도구",
'trajectory.kind.compact': "압축",
'trajectory.turn.n': "{n}턴",
'trajectory.turn.before': "시작 전",
'trajectory.system.initial': "초기 시스템 프롬프트",
'trajectory.system.updated': "시스템 프롬프트 업데이트",
'trajectory.system.toolsUpdated': "도구 업데이트",
'trajectory.system.bothUpdated': "시스템 프롬프트와 도구 업데이트",
'trajectory.system.userContext': "사용자 컨텍스트 (CLAUDE.md, 날짜)",
'trajectory.system.chars': "{count}자",
'trajectory.context.skill': "스킬",
'trajectory.context.skillListing': "스킬 목록",
'trajectory.context.invokedSkills': "호출된 스킬",
'trajectory.context.dynamicSkill': "동적 스킬",
'trajectory.context.skillDiscovery': "스킬 탐색",
'trajectory.context.meta': "주입된 내용",
'trajectory.context.summary': "압축 요약",
'trajectory.context.userContext': "사용자 컨텍스트",
'trajectory.context.todoReminder': "할 일 알림",
'trajectory.context.taskReminder': "작업 리마인더",
'trajectory.context.memory': "메모리",
'trajectory.context.planMode': "계획 모드",
'trajectory.context.autoMode': "자동 모드",
'trajectory.context.dateChange': "날짜 변경",
'trajectory.context.toolsDelta': "도구 변경",
'trajectory.context.agentsDelta': "에이전트 목록",
'trajectory.context.mcpInstructions': "MCP 지침",
'trajectory.context.mcpResource': "MCP 리소스",
'trajectory.context.reminder': "시스템 알림",
'trajectory.context.diagnostics': "진단",
'trajectory.context.file': "파일",
'trajectory.context.fileChange': "파일 변경",
'trajectory.context.ide': "IDE",
'trajectory.context.queued': "추가 입력",
'trajectory.context.taskStatus': "작업 상태",
'trajectory.context.budget': "예산",
'trajectory.context.team': "팀",
'trajectory.context.apiError': "API 오류",
'trajectory.context.hook': "훅",
'trajectory.context.taskNotification': "작업 알림",
'trajectory.context.commandOutput': "명령 출력",
'trajectory.context.interrupted': "중단됨",
'trajectory.user.queued': "추가 입력",
'trajectory.assistant.toolOnly': "(도구 호출만)",
'trajectory.assistant.thinkingOnly': "(사고만)",
'trajectory.tool.unknown': "도구",
'trajectory.tool.running': "실행 중…",
'trajectory.tool.emptyResult': "(비어 있음)",
'trajectory.compact.title': "컨텍스트 압축됨",
'trajectory.status.done': "완료",
'trajectory.status.error': "실패",
'trajectory.table.label': "궤적",
'trajectory.table.folded': "{count}행 접힘",
'trajectory.table.toolCount': "도구 {count}개",
'trajectory.table.collapseTurn': "{turn} 접기",
'trajectory.table.expandTurn': "{turn} 펼치기",
'trajectory.table.loadOlder': "이전 기록 불러오기",
'trajectory.table.searchLoaded': "불러온 기록만 검색 · 이전 불러오기",
'trajectory.minimap.input': "입력",
'trajectory.minimap.model': "모델",
'trajectory.minimap.tools': "도구",
'trajectory.toolbar.duration': "소요 시간",
'trajectory.toolbar.collapseTurns': "턴 접기",
'trajectory.toolbar.hideTools': "도구 숨기기",
'trajectory.toolbar.search': "궤적 검색",
'trajectory.toolbar.clearSearch': "검색 지우기",
'trajectory.toolbar.totals': "{turns}턴 · {steps}단계 · 호출 {tools}회 · {tokens} tok",
'trajectory.toolbar.cacheHit': "캐시 적중 {percent}%",
'trajectory.subagent.breadcrumb': "서브에이전트 궤적",
'trajectory.subagent.main': "메인 세션",
'trajectory.subagent.view': "서브에이전트 궤적 보기",
'trajectory.tab.overview': "개요",
'trajectory.tab.preview': "미리보기",
'trajectory.tab.input': "입력",
'trajectory.tab.output': "결과",
'trajectory.tab.raw': "원본",
'trajectory.tab.request': "원본 요청",
'trajectory.tab.prompt': "시스템 프롬프트",
'trajectory.tab.tools': "도구",
'trajectory.tab.context': "내용",
'trajectory.tab.diff': "차이",
'trajectory.detail.label': "궤적 상세",
'trajectory.detail.tabs': "상세 섹션",
'trajectory.detail.resize': "상세 패널 너비 조절",
'trajectory.detail.copy': "복사",
'trajectory.detail.loadFailed': "불러오기 실패: {error}",
'trajectory.detail.notLoaded': "이 기록에 없습니다",
'trajectory.detail.truncated': "내용이 너무 커서 앞부분만 표시합니다.",
'trajectory.detail.empty': "표시할 내용이 없습니다",
'trajectory.detail.time': "시간",
'trajectory.detail.source': "출처",
'trajectory.detail.status': "상태",
'trajectory.detail.model': "모델",
'trajectory.detail.stopReason': "중지 사유",
'trajectory.detail.tokens': "토큰",
'trajectory.detail.tokenBreakdown': "입력 {input} · 캐시 읽기 {cacheRead} · 캐시 쓰기 {cacheWrite} · 출력 {output}",
'trajectory.detail.duration': "소요 시간",
'trajectory.detail.approx': "약 {value}",
'trajectory.detail.issuedBy': "호출 주체",
'trajectory.detail.locateInChat': "대화에서 찾기",
'trajectory.detail.thinking': "사고 과정",
'trajectory.detail.resultText': "모델에 전달된 결과",
'trajectory.detail.resultStructured': "구조화된 결과",
'trajectory.detail.systemPrompt': "시스템 프롬프트 · {count}자",
'trajectory.detail.systemPromptDiff': "시스템 프롬프트 변경",
'trajectory.detail.toolsDiff': "도구 변경",
'trajectory.detail.toolsAdded': "추가",
'trajectory.detail.toolsRemoved': "제거",
'trajectory.detail.toolsChanged': "변경",
'trajectory.detail.noChanges': "변경 없음",
'trajectory.detail.unchangedLines': "변경 없는 {count}줄",
'trajectory.request.closest': "시간 기준으로 가장 가까운 캡처 호출입니다. 요청과 대화 기록은 따로 저장되므로 대응은 근사치입니다.",
'trajectory.toolbar.durationHint': "미니맵을 실제 소요 시간으로 펼쳐 느린 호출을 넓게 표시",
'trajectory.toolbar.loadedFrom': "{turn}턴부터 (불러온 범위)",
'trajectory.toolbar.matches': "{count}개 일치",
'trajectory.toolbar.totalsHint': "집계는 불러온 기록 기준이며 서브에이전트 자체 토큰은 포함하지 않습니다",
'trajectory.table.errorCount': "{count}건 실패",
'trajectory.table.usageTitle': "입력 토큰(캐시 포함) ↑ / 출력 토큰 ↓",
'trajectory.table.usageShort': "{input}↑ {output}↓",
'trajectory.tool.callEarlier': "(호출 기록은 이전 기록에 있음)",
'trajectory.tool.resultTooLarge': "결과가 너무 커서 불러오지 않음 ({size})",
'trajectory.tool.noResult': "결과 없음",
'trajectory.minimap.label': "궤적 미니맵: 입력, 모델, 도구 레인",
'trajectory.search.noResults': "일치하는 기록이 없습니다",
'trajectory.search.noResultsHint': "다른 키워드로 시도해 보세요.",
'trajectory.search.noResultsPartial': "불러온 기록만 검색했습니다. 이전 기록을 불러온 뒤 다시 검색하세요.",
'trajectory.detail.back': "목록으로",
'trajectory.detail.images': "이미지 {count}장 포함",
'trajectory.request.captureToggle': "원본 요청 기록 (Agent Trace)",
'trajectory.request.captureHint': "켜면 새 모델 호출에서 실제로 보낸 요청과 받은 응답을 저장합니다.",
'trajectory.request.uncaptured': "이 응답이 실행될 때 원본 요청 기록이 꺼져 있었습니다.",
'trajectory.request.unmatched': "이 응답에 해당하는 호출을 찾지 못했습니다.",
'trajectory.user.command': "명령",
'trajectory.user.sessionMessage': "세션 메시지",
'trajectory.context.apiRetry': "API 재시도",
'trajectory.context.localCommand': "로컬 명령",
'trajectory.context.streamRetry': "스트림 재시도",
'trajectory.context.informational': "알림",
'trajectory.context.awaySummary': "부재 요약",
'trajectory.context.scheduledTask': "예약 작업",
'trajectory.context.environment': "환경 정보",
'trajectory.context.instructions': "지침",
'trajectory.context.sessionContext': "세션 컨텍스트",
'trajectory.context.permissionRetry': "권한 허용",
'trajectory.context.memorySaved': "메모리 저장됨",
'trajectory.context.agentsKilled': "에이전트 중지됨",
'trajectory.context.modelFallback': "모델 대체",
'trajectory.request.endpoint': "요청 주소",
'trajectory.request.provider': "제공자",
'trajectory.request.route': "경로",
'trajectory.request.routeDirect': "SDK가 직접 전송",
'trajectory.request.routeProxy': "로컬 프록시에서 프로토콜 변환 후 업스트림으로 전송",
'trajectory.request.model': "요청 모델",
'trajectory.request.upstreamModel': "업스트림 전송 모델",
'trajectory.request.respondedModel': "응답에 표시된 모델",
'trajectory.request.status': "결과",
'trajectory.request.requestId': "요청 ID",
'trajectory.request.purpose': "호출 출처",
}
+173 -103
View File
@@ -987,7 +987,6 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.providers': '模型設定',
'settings.tab.permissions': '許可權',
'settings.tab.activity': 'Token 用量',
'settings.tab.trace': 'Trace',
'settings.tab.general': '通用',
'settings.tab.h5Access': 'H5 訪問',
'settings.tab.terminal': '終端',
@@ -2447,8 +2446,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '收集本地會話的模型請求鏈路,用於排查卡住、失敗和異常等待。',
'settings.general.traceEnabled': '收集 Agent Trace',
'settings.general.traceHintOn': '新會話會把精簡請求、回應、狀態事件寫入本機 cc-haha traces 目錄。',
'settings.general.traceHintOff': '關閉後不會寫入新的 Trace;已有記錄仍可在 Trace 列表裡查看。',
'settings.general.traceHintOn': '新會話會把精簡請求、回應和狀態事件寫入本機 cc-haha traces 目錄。在會話的「軌跡」檢視中點開助理列,切到「原始請求」即可查看。',
'settings.general.traceHintOff': '關閉後不會再記錄新的請求;已記錄的內容仍可在會話「軌跡」檢視的「原始請求」中查看。',
'settings.general.chatSendBehaviorTitle': '訊息傳送方式',
'settings.general.chatSendBehaviorDescription': '選擇桌面端對話輸入框如何傳送訊息。',
'settings.general.chatSendBehaviorEnter': 'Enter 傳送',
@@ -3558,51 +3557,7 @@ export const zh: Record<TranslationKey, string> = {
'session.timeMinutes': '{n}分鐘前',
'session.timeHours': '{n}小時前',
'session.timeDays': '{n}天前',
'trace.open': 'Trace',
'trace.title': '模型 Trace',
'trace.loadFailed': 'Trace 載入失敗',
'trace.apiCalls': 'API 呼叫',
'trace.failedCalls': '失敗',
'trace.duration': '耗時',
'trace.elapsed': '已用時',
'trace.completed': '完成',
'trace.wallTime': '牆鐘時間',
'trace.modelTime': '模型耗時',
'trace.toolTime': '工具耗時',
'trace.models': '模型',
'trace.request': '請求',
'trace.response': '回應',
'trace.status': '狀態',
'trace.live': '即時',
'trace.updatedAt': '更新',
'trace.copySessionId': '複製 Session ID',
'trace.refresh': '重新整理 Trace',
'trace.openWindow': '在獨立視窗開啟',
'trace.backToList': '返回列表',
'trace.delete': '刪除 Trace',
'trace.noResponse': '未捕獲回應正文',
'trace.emptyTitle': '暫無 Trace 呼叫',
'trace.emptyBody': '這個會話還沒有捕獲到模型呼叫。新的桌面會話會自動記錄精簡的請求和回應預覽。',
'trace.tokens': '輸入 + 輸出',
'trace.inputOutputTokens': '輸入 → 輸出',
'trace.cacheTokens': '快取讀取 → 寫入',
'trace.snapshotEmpty': 'Trace 快照為空。',
'trace.errors': '錯誤',
'trace.focus': '關注點',
'trace.searchSpans': '搜尋 Span',
'trace.noMatchingSpans': '沒有符合的 Span',
'trace.filter.aria': '篩選 Span',
'trace.filter.all': '全部',
'trace.filter.llm': 'LLM',
'trace.filter.tools': '工具',
'trace.filter.errors': '錯誤',
'trace.sidechain': '子鏈',
'trace.turnLabel': '第 {index} 輪',
'trace.waitingForResult': '等待結果',
'trace.started': '開始',
'trace.provider': 'Provider',
'trace.model': '模型',
'trace.childSpans': '子 Span',
'trace.requestBody': '請求正文',
'trace.requestHeaders': '請求頭',
'trace.responseBody': '回應正文',
@@ -3610,36 +3565,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.toolResult': '工具結果',
'trace.truncatedShort': '已截斷',
'trace.noData': '無資料',
'trace.llmCalls': 'LLM 呼叫',
'trace.toolCalls': '工具呼叫',
'trace.status.ok': '正常',
'trace.status.error': '錯誤',
'trace.status.pending': '等待中',
'trace.message.user': '使用者訊息',
'trace.message.assistant': '助手訊息',
'trace.message.redactedReasoning': '推理內容由服務方隱去',
'trace.message.system': '系統訊息',
'trace.message.toolRequest': '助手請求工具',
'trace.message.toolResult': '工具結果',
'trace.toolError': '工具錯誤',
'trace.modelCall': '模型呼叫',
'trace.sessionActivity': '會話活動',
'trace.diagnosis.modelError': '模型呼叫失敗',
'trace.diagnosis.toolError': '工具執行失敗',
'trace.diagnosis.eventError': '鏈路事件失敗',
'trace.diagnosis.pendingModel': '正在等待模型回應',
'trace.diagnosis.pendingTool': '正在等待工具結果',
'trace.diagnosis.waitingForAgent': '使用者訊息尚未進入 agent 步驟',
'trace.diagnosis.empty': '沒有可診斷事件',
'trace.diagnosis.healthy': '未發現阻塞點',
'trace.event.apiCallStarted': 'API 呼叫開始',
'trace.event.apiCallCompleted': 'API 呼叫完成',
'trace.event.apiCallFailed': 'API 呼叫失敗',
'trace.event.apiCallAborted': 'API 呼叫中斷',
'trace.event.responseCaptureFailed': '回應捕獲失敗',
'trace.event.upstreamFetchStarted': '上游請求開始',
'trace.event.upstreamFetchCompleted': '上游請求完成',
'trace.event.upstreamFetchFailed': '上游請求失敗',
'trace.section.response': '回應',
'trace.section.messages': '訊息',
'trace.section.systemPrompt': '系統提示詞',
@@ -3650,12 +3576,6 @@ export const zh: Record<TranslationKey, string> = {
'trace.section.tools': '工具',
'trace.section.parameters': '參數',
'trace.section.raw': '原始資料',
'trace.section.input': '輸入',
'trace.section.result': '結果',
'trace.section.meta': '中繼資訊',
'trace.section.content': '內容',
'trace.section.event': '事件',
'trace.section.metadata': '中繼資料',
'trace.detail.stopReason': '停止原因',
'trace.detail.streaming': '等待模型回應...',
'trace.detail.aborted': '請求在回應完成前被中斷(逾時或取消)。',
@@ -3665,27 +3585,6 @@ export const zh: Record<TranslationKey, string> = {
'trace.detail.earlierMessages': '展開更早的 {count} 則訊息',
'trace.detail.chars': '{count} 字元',
'trace.detail.thinking': '思考',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': '階段',
'trace.detail.severity': '層級',
'trace.detail.message': '訊息',
'trace.tree.aria': 'Trace 時間軸',
'trace.tree.toggleTurn': '摺疊/展開輪次',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 列表',
'trace.list.collecting': '正在收集',
'trace.list.paused': '已暫停',
'trace.list.settings': 'Trace 設定',
'trace.list.sessions': '會話',
'trace.list.searchPlaceholder': '搜尋標題、Session ID 或專案路徑',
'trace.list.emptyTitle': '還沒有 Trace 記錄',
'trace.list.emptyBody': '開啟收集後,新的桌面會話會把模型呼叫鏈路寫入本地 traces 目錄。',
'trace.list.loadFailed': 'Trace 列表載入失敗',
'trace.list.loadedCount': '已顯示 {shown} / {total}',
'trace.list.loadMore': '載入更多',
'trace.list.deleteConfirmTitle': '刪除 Trace 會話',
'trace.list.deleteConfirmBody': '刪除「{title}」的 Trace 資料?聊天記錄不會被刪除。',
'trace.list.deleteFailed': 'Trace 會話刪除失敗',
// ─── 應用外殼 ──────────────────────────────────────
'app.serverFailed': '本地服務啟動失敗',
@@ -3960,6 +3859,7 @@ export const zh: Record<TranslationKey, string> = {
'mermaid.fitDiagram': '縮放至適應圖表',
'codeViewer.collapse': '收合',
'codeViewer.showMoreLines': '顯示其餘 {count} 行',
'common.copy': '複製',
// ─── Browser Panel ──────────────────────────────────────
'browser.back': '上一頁',
@@ -4129,4 +4029,174 @@ export const zh: Record<TranslationKey, string> = {
'voice.composer.error.failed': '辨識失敗,請重試。',
'voice.composer.error.noSpeech': '未辨識到語音。',
'voice.composer.error.tooShort': '錄音時間太短。',
'trajectory.switch.label': "會話視圖",
'trajectory.switch.chat': "對話",
'trajectory.switch.trajectory': "軌跡",
'trajectory.viewInTrajectory': "在軌跡中查看",
'trajectory.loading': "正在載入軌跡…",
'trajectory.loadFailed': "軌跡載入失敗",
'trajectory.empty.title': "還沒有軌跡",
'trajectory.empty.description': "傳送訊息後,每一次模型呼叫、工具呼叫和注入的上下文都會顯示在這裡。",
'trajectory.kind.system': "系統",
'trajectory.kind.user': "使用者",
'trajectory.kind.context': "上下文",
'trajectory.kind.assistant': "助理",
'trajectory.kind.tool': "工具",
'trajectory.kind.compact': "壓縮",
'trajectory.turn.n': "第 {n} 輪",
'trajectory.turn.before': "開始前",
'trajectory.system.initial': "初始系統提示詞",
'trajectory.system.updated': "系統提示詞已更新",
'trajectory.system.toolsUpdated': "工具已更新",
'trajectory.system.bothUpdated': "系統提示詞和工具已更新",
'trajectory.system.userContext': "使用者上下文(CLAUDE.md、日期)",
'trajectory.system.chars': "{count} 字元",
'trajectory.context.skill': "技能",
'trajectory.context.skillListing': "技能清單",
'trajectory.context.invokedSkills': "已呼叫技能",
'trajectory.context.dynamicSkill': "動態技能",
'trajectory.context.skillDiscovery': "技能發現",
'trajectory.context.meta': "注入內容",
'trajectory.context.summary': "壓縮摘要",
'trajectory.context.userContext': "使用者上下文",
'trajectory.context.todoReminder': "待辦提醒",
'trajectory.context.taskReminder': "任務提醒",
'trajectory.context.memory': "記憶",
'trajectory.context.planMode': "計畫模式",
'trajectory.context.autoMode': "自動模式",
'trajectory.context.dateChange': "日期變更",
'trajectory.context.toolsDelta': "工具變更",
'trajectory.context.agentsDelta': "代理清單",
'trajectory.context.mcpInstructions': "MCP 說明",
'trajectory.context.mcpResource': "MCP 資源",
'trajectory.context.reminder': "系統提醒",
'trajectory.context.diagnostics': "診斷",
'trajectory.context.file': "檔案",
'trajectory.context.fileChange': "檔案變更",
'trajectory.context.ide': "IDE",
'trajectory.context.queued': "插入訊息",
'trajectory.context.taskStatus': "任務狀態",
'trajectory.context.budget': "預算",
'trajectory.context.team': "團隊",
'trajectory.context.apiError': "API 錯誤",
'trajectory.context.hook': "Hook",
'trajectory.context.taskNotification': "任務通知",
'trajectory.context.commandOutput': "指令輸出",
'trajectory.context.interrupted': "已中斷",
'trajectory.user.queued': "插入",
'trajectory.assistant.toolOnly': "(僅工具呼叫)",
'trajectory.assistant.thinkingOnly': "(僅思考)",
'trajectory.tool.unknown': "工具",
'trajectory.tool.running': "執行中…",
'trajectory.tool.emptyResult': "(空)",
'trajectory.compact.title': "上下文已壓縮",
'trajectory.status.done': "已完成",
'trajectory.status.error': "失敗",
'trajectory.table.label': "軌跡",
'trajectory.table.folded': "已摺疊 {count} 行",
'trajectory.table.toolCount': "{count} 個工具",
'trajectory.table.collapseTurn': "摺疊{turn}",
'trajectory.table.expandTurn': "展開{turn}",
'trajectory.table.loadOlder': "載入更早的紀錄",
'trajectory.table.searchLoaded': "僅搜尋已載入的紀錄 · 載入更早",
'trajectory.minimap.input': "輸入",
'trajectory.minimap.model': "模型",
'trajectory.minimap.tools': "工具",
'trajectory.toolbar.duration': "依耗時",
'trajectory.toolbar.collapseTurns': "摺疊輪次",
'trajectory.toolbar.hideTools': "隱藏工具",
'trajectory.toolbar.search': "搜尋軌跡",
'trajectory.toolbar.clearSearch': "清除搜尋",
'trajectory.toolbar.totals': "{turns} 輪 · {steps} 步 · {tools} 次呼叫 · {tokens} tok",
'trajectory.toolbar.cacheHit': "快取命中 {percent}%",
'trajectory.subagent.breadcrumb': "子代理軌跡",
'trajectory.subagent.main': "主會話",
'trajectory.subagent.view': "查看子代理軌跡",
'trajectory.tab.overview': "概述",
'trajectory.tab.preview': "預覽",
'trajectory.tab.input': "參數",
'trajectory.tab.output': "結果",
'trajectory.tab.raw': "原始內容",
'trajectory.tab.request': "原始請求",
'trajectory.tab.prompt': "系統提示詞",
'trajectory.tab.tools': "工具",
'trajectory.tab.context': "內容",
'trajectory.tab.diff': "差異",
'trajectory.detail.label': "軌跡詳情",
'trajectory.detail.tabs': "詳情分區",
'trajectory.detail.resize': "調整詳情面板寬度",
'trajectory.detail.copy': "複製",
'trajectory.detail.loadFailed': "載入失敗:{error}",
'trajectory.detail.notLoaded': "該紀錄中沒有此內容",
'trajectory.detail.truncated': "內容過大,只顯示開頭部分。",
'trajectory.detail.empty': "沒有可顯示的內容",
'trajectory.detail.time': "時間",
'trajectory.detail.source': "來源",
'trajectory.detail.status': "狀態",
'trajectory.detail.model': "模型",
'trajectory.detail.stopReason': "停止原因",
'trajectory.detail.tokens': "Tokens",
'trajectory.detail.tokenBreakdown': "輸入 {input} · 快取讀 {cacheRead} · 快取寫 {cacheWrite} · 輸出 {output}",
'trajectory.detail.duration': "耗時",
'trajectory.detail.approx': "約 {value}",
'trajectory.detail.issuedBy': "發起方",
'trajectory.detail.locateInChat': "在對話中定位",
'trajectory.detail.thinking': "思考過程",
'trajectory.detail.resultText': "回傳給模型的結果",
'trajectory.detail.resultStructured': "結構化結果",
'trajectory.detail.systemPrompt': "系統提示詞 · {count} 字元",
'trajectory.detail.systemPromptDiff': "系統提示詞變化",
'trajectory.detail.toolsDiff': "工具變化",
'trajectory.detail.toolsAdded': "新增",
'trajectory.detail.toolsRemoved': "移除",
'trajectory.detail.toolsChanged': "修改",
'trajectory.detail.noChanges': "沒有變化",
'trajectory.detail.unchangedLines': "{count} 行未變化",
'trajectory.request.closest': "依時間比對到最接近的一次擷取呼叫。請求與對話紀錄分開儲存,因此對應關係是近似的。",
'trajectory.toolbar.durationHint': "小地圖依實際耗時鋪開,慢的呼叫更寬",
'trajectory.toolbar.loadedFrom': "自第 {turn} 輪起(已載入部分)",
'trajectory.toolbar.matches': "{count} 筆符合",
'trajectory.toolbar.totalsHint': "統計範圍是已載入的紀錄,不含子代理自己的 token",
'trajectory.table.errorCount': "{count} 次失敗",
'trajectory.table.usageTitle': "輸入 token(含快取)↑ / 輸出 token ↓",
'trajectory.table.usageShort': "{input}↑ {output}↓",
'trajectory.tool.callEarlier': "(呼叫紀錄在更早的歷史中)",
'trajectory.tool.resultTooLarge': "結果過大({size}),未載入",
'trajectory.tool.noResult': "無結果",
'trajectory.minimap.label': "軌跡小地圖:輸入、模型、工具三條泳道",
'trajectory.search.noResults': "沒有符合的紀錄",
'trajectory.search.noResultsHint': "換個關鍵字試試。",
'trajectory.search.noResultsPartial': "只搜尋了已載入的紀錄,可以載入更早的紀錄後再搜。",
'trajectory.detail.back': "返回列表",
'trajectory.detail.images': "包含 {count} 張圖片",
'trajectory.request.captureToggle': "記錄原始請求(Agent Trace)",
'trajectory.request.captureHint': "開啟後,新的模型呼叫會儲存實際送出的請求與收到的回應。",
'trajectory.request.uncaptured': "這條回覆發生時沒有開啟原始請求記錄。",
'trajectory.request.unmatched': "沒有找到與這條回覆對應的呼叫。",
'trajectory.user.command': "指令",
'trajectory.user.sessionMessage': "會話訊息",
'trajectory.context.apiRetry': "API 重試",
'trajectory.context.localCommand': "本機指令",
'trajectory.context.streamRetry': "串流重試",
'trajectory.context.informational': "提示",
'trajectory.context.awaySummary': "離開摘要",
'trajectory.context.scheduledTask': "排程任務",
'trajectory.context.environment': "環境資訊",
'trajectory.context.instructions': "指令說明",
'trajectory.context.sessionContext': "會話上下文",
'trajectory.context.permissionRetry': "權限已放行",
'trajectory.context.memorySaved': "已儲存記憶",
'trajectory.context.agentsKilled': "已停止代理",
'trajectory.context.modelFallback': "模型回退",
'trajectory.request.endpoint': "請求位址",
'trajectory.request.provider': "服務商",
'trajectory.request.route': "鏈路",
'trajectory.request.routeDirect': "SDK 直連服務商",
'trajectory.request.routeProxy': "經本機代理轉換協定後送往上游",
'trajectory.request.model': "請求模型",
'trajectory.request.upstreamModel': "實際送出的模型",
'trajectory.request.respondedModel': "回應聲明的模型",
'trajectory.request.status': "結果",
'trajectory.request.requestId': "請求 ID",
'trajectory.request.purpose': "呼叫來源",
}
+173 -103
View File
@@ -986,7 +986,6 @@ export const zh: Record<TranslationKey, string> = {
'settings.tab.providers': '模型配置',
'settings.tab.permissions': '权限',
'settings.tab.activity': 'Token 用量',
'settings.tab.trace': 'Trace',
'settings.tab.general': '通用',
'settings.tab.h5Access': 'H5 访问',
'settings.tab.terminal': '终端',
@@ -2446,8 +2445,8 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.traceTitle': 'Agent Trace',
'settings.general.traceDescription': '收集本地会话的模型请求链路,用于排查卡住、失败和异常等待。',
'settings.general.traceEnabled': '收集 Agent Trace',
'settings.general.traceHintOn': '新会话会把精简请求、响应、状态事件写入本机 cc-haha traces 目录。',
'settings.general.traceHintOff': '关闭后不会写入新的 Trace;已有记录仍可在 Trace 列表里查看。',
'settings.general.traceHintOn': '新会话会把精简请求、响应和状态事件写入本机 cc-haha traces 目录。在会话的「轨迹」视图中点开助手行,切到「原始请求」即可查看。',
'settings.general.traceHintOff': '关闭后不会再记录新的请求;已记录的内容仍可在会话「轨迹」视图的「原始请求」中查看。',
'settings.general.chatSendBehaviorTitle': '消息发送方式',
'settings.general.chatSendBehaviorDescription': '选择桌面端对话输入框如何发送消息。',
'settings.general.chatSendBehaviorEnter': 'Enter 发送',
@@ -3557,51 +3556,7 @@ export const zh: Record<TranslationKey, string> = {
'session.timeMinutes': '{n}分钟前',
'session.timeHours': '{n}小时前',
'session.timeDays': '{n}天前',
'trace.open': 'Trace',
'trace.title': '模型 Trace',
'trace.loadFailed': 'Trace 加载失败',
'trace.apiCalls': 'API 调用',
'trace.failedCalls': '失败',
'trace.duration': '耗时',
'trace.elapsed': '已用时',
'trace.completed': '完成',
'trace.wallTime': '墙钟时间',
'trace.modelTime': '模型耗时',
'trace.toolTime': '工具耗时',
'trace.models': '模型',
'trace.request': '请求',
'trace.response': '响应',
'trace.status': '状态',
'trace.live': '实时',
'trace.updatedAt': '更新',
'trace.copySessionId': '复制 Session ID',
'trace.refresh': '刷新 Trace',
'trace.openWindow': '在独立窗口打开',
'trace.backToList': '返回列表',
'trace.delete': '删除 Trace',
'trace.noResponse': '未捕获响应正文',
'trace.emptyTitle': '暂无 Trace 调用',
'trace.emptyBody': '这个会话还没有捕获到模型调用。新的桌面会话会自动记录精简的请求和响应预览。',
'trace.tokens': '输入 + 输出',
'trace.inputOutputTokens': '输入 → 输出',
'trace.cacheTokens': '缓存读取 → 写入',
'trace.snapshotEmpty': 'Trace 快照为空。',
'trace.errors': '错误',
'trace.focus': '关注点',
'trace.searchSpans': '搜索 Span',
'trace.noMatchingSpans': '没有匹配的 Span',
'trace.filter.aria': '筛选 Span',
'trace.filter.all': '全部',
'trace.filter.llm': 'LLM',
'trace.filter.tools': '工具',
'trace.filter.errors': '错误',
'trace.sidechain': '子链',
'trace.turnLabel': '第 {index} 轮',
'trace.waitingForResult': '等待结果',
'trace.started': '开始',
'trace.provider': 'Provider',
'trace.model': '模型',
'trace.childSpans': '子 Span',
'trace.requestBody': '请求正文',
'trace.requestHeaders': '请求头',
'trace.responseBody': '响应正文',
@@ -3609,36 +3564,7 @@ export const zh: Record<TranslationKey, string> = {
'trace.toolResult': '工具结果',
'trace.truncatedShort': '已截断',
'trace.noData': '无数据',
'trace.llmCalls': 'LLM 调用',
'trace.toolCalls': '工具调用',
'trace.status.ok': '正常',
'trace.status.error': '错误',
'trace.status.pending': '等待中',
'trace.message.user': '用户消息',
'trace.message.assistant': '助手消息',
'trace.message.redactedReasoning': '推理内容由服务方隐去',
'trace.message.system': '系统消息',
'trace.message.toolRequest': '助手请求工具',
'trace.message.toolResult': '工具结果',
'trace.toolError': '工具错误',
'trace.modelCall': '模型调用',
'trace.sessionActivity': '会话活动',
'trace.diagnosis.modelError': '模型调用失败',
'trace.diagnosis.toolError': '工具执行失败',
'trace.diagnosis.eventError': '链路事件失败',
'trace.diagnosis.pendingModel': '正在等待模型响应',
'trace.diagnosis.pendingTool': '正在等待工具结果',
'trace.diagnosis.waitingForAgent': '用户消息尚未进入 agent 步骤',
'trace.diagnosis.empty': '没有可诊断事件',
'trace.diagnosis.healthy': '未发现阻塞点',
'trace.event.apiCallStarted': 'API 调用开始',
'trace.event.apiCallCompleted': 'API 调用完成',
'trace.event.apiCallFailed': 'API 调用失败',
'trace.event.apiCallAborted': 'API 调用中断',
'trace.event.responseCaptureFailed': '响应捕获失败',
'trace.event.upstreamFetchStarted': '上游请求开始',
'trace.event.upstreamFetchCompleted': '上游请求完成',
'trace.event.upstreamFetchFailed': '上游请求失败',
'trace.section.response': '响应',
'trace.section.messages': '消息',
'trace.section.systemPrompt': '系统提示词',
@@ -3649,12 +3575,6 @@ export const zh: Record<TranslationKey, string> = {
'trace.section.tools': '工具',
'trace.section.parameters': '参数',
'trace.section.raw': '原始数据',
'trace.section.input': '输入',
'trace.section.result': '结果',
'trace.section.meta': '元信息',
'trace.section.content': '内容',
'trace.section.event': '事件',
'trace.section.metadata': '元数据',
'trace.detail.stopReason': '停止原因',
'trace.detail.streaming': '等待模型响应...',
'trace.detail.aborted': '请求在响应完成前被中断(超时或取消)。',
@@ -3664,27 +3584,6 @@ export const zh: Record<TranslationKey, string> = {
'trace.detail.earlierMessages': '展开更早的 {count} 条消息',
'trace.detail.chars': '{count} 字符',
'trace.detail.thinking': '思考',
'trace.detail.toolUseId': 'Tool use ID',
'trace.detail.phase': '阶段',
'trace.detail.severity': '级别',
'trace.detail.message': '消息',
'trace.tree.aria': 'Trace 时间线',
'trace.tree.toggleTurn': '折叠/展开轮次',
'trace.list.eyebrow': 'Agent Trace',
'trace.list.title': 'Trace 列表',
'trace.list.collecting': '正在收集',
'trace.list.paused': '已暂停',
'trace.list.settings': 'Trace 设置',
'trace.list.sessions': '会话',
'trace.list.searchPlaceholder': '搜索标题、Session ID 或项目路径',
'trace.list.emptyTitle': '还没有 Trace 记录',
'trace.list.emptyBody': '开启收集后,新的桌面会话会把模型调用链路写入本地 traces 目录。',
'trace.list.loadFailed': 'Trace 列表加载失败',
'trace.list.loadedCount': '已显示 {shown} / {total}',
'trace.list.loadMore': '加载更多',
'trace.list.deleteConfirmTitle': '删除 Trace 会话',
'trace.list.deleteConfirmBody': '删除“{title}”的 Trace 数据?聊天记录不会被删除。',
'trace.list.deleteFailed': 'Trace 会话删除失败',
// ─── 应用外壳 ──────────────────────────────────────
'app.serverFailed': '本地服务启动失败',
@@ -3959,6 +3858,7 @@ export const zh: Record<TranslationKey, string> = {
'mermaid.fitDiagram': '缩放至适应图表',
'codeViewer.collapse': '收起',
'codeViewer.showMoreLines': '显示其余 {count} 行',
'common.copy': '复制',
// ─── Browser Panel ──────────────────────────────────────
'browser.back': '后退',
@@ -4128,4 +4028,174 @@ export const zh: Record<TranslationKey, string> = {
'voice.composer.error.failed': '识别失败,请重试。',
'voice.composer.error.noSpeech': '未识别到语音。',
'voice.composer.error.tooShort': '录音时间太短。',
'trajectory.switch.label': "会话视图",
'trajectory.switch.chat': "对话",
'trajectory.switch.trajectory': "轨迹",
'trajectory.viewInTrajectory': "在轨迹中查看",
'trajectory.loading': "正在加载轨迹…",
'trajectory.loadFailed': "轨迹加载失败",
'trajectory.empty.title': "还没有轨迹",
'trajectory.empty.description': "发送消息后,每一次模型调用、工具调用和注入的上下文都会显示在这里。",
'trajectory.kind.system': "系统",
'trajectory.kind.user': "用户",
'trajectory.kind.context': "上下文",
'trajectory.kind.assistant': "助手",
'trajectory.kind.tool': "工具",
'trajectory.kind.compact': "压缩",
'trajectory.turn.n': "第 {n} 轮",
'trajectory.turn.before': "开始前",
'trajectory.system.initial': "初始系统提示词",
'trajectory.system.updated': "系统提示词已更新",
'trajectory.system.toolsUpdated': "工具已更新",
'trajectory.system.bothUpdated': "系统提示词和工具已更新",
'trajectory.system.userContext': "用户上下文(CLAUDE.md、日期)",
'trajectory.system.chars': "{count} 字符",
'trajectory.context.skill': "技能",
'trajectory.context.skillListing': "技能列表",
'trajectory.context.invokedSkills': "已调用技能",
'trajectory.context.dynamicSkill': "动态技能",
'trajectory.context.skillDiscovery': "技能发现",
'trajectory.context.meta': "注入内容",
'trajectory.context.summary': "压缩摘要",
'trajectory.context.userContext': "用户上下文",
'trajectory.context.todoReminder': "待办提醒",
'trajectory.context.taskReminder': "任务提醒",
'trajectory.context.memory': "记忆",
'trajectory.context.planMode': "计划模式",
'trajectory.context.autoMode': "自动模式",
'trajectory.context.dateChange': "日期变更",
'trajectory.context.toolsDelta': "工具变更",
'trajectory.context.agentsDelta': "代理列表",
'trajectory.context.mcpInstructions': "MCP 说明",
'trajectory.context.mcpResource': "MCP 资源",
'trajectory.context.reminder': "系统提醒",
'trajectory.context.diagnostics': "诊断",
'trajectory.context.file': "文件",
'trajectory.context.fileChange': "文件变更",
'trajectory.context.ide': "IDE",
'trajectory.context.queued': "插入消息",
'trajectory.context.taskStatus': "任务状态",
'trajectory.context.budget': "预算",
'trajectory.context.team': "团队",
'trajectory.context.apiError': "API 错误",
'trajectory.context.hook': "Hook",
'trajectory.context.taskNotification': "任务通知",
'trajectory.context.commandOutput': "命令输出",
'trajectory.context.interrupted': "已中断",
'trajectory.user.queued': "插入",
'trajectory.assistant.toolOnly': "(仅工具调用)",
'trajectory.assistant.thinkingOnly': "(仅思考)",
'trajectory.tool.unknown': "工具",
'trajectory.tool.running': "执行中…",
'trajectory.tool.emptyResult': "(空)",
'trajectory.compact.title': "上下文已压缩",
'trajectory.status.done': "已完成",
'trajectory.status.error': "失败",
'trajectory.table.label': "轨迹",
'trajectory.table.folded': "已折叠 {count} 行",
'trajectory.table.toolCount': "{count} 个工具",
'trajectory.table.collapseTurn': "折叠{turn}",
'trajectory.table.expandTurn': "展开{turn}",
'trajectory.table.loadOlder': "加载更早的记录",
'trajectory.table.searchLoaded': "仅搜索已加载的记录 · 加载更早",
'trajectory.minimap.input': "输入",
'trajectory.minimap.model': "模型",
'trajectory.minimap.tools': "工具",
'trajectory.toolbar.duration': "按耗时",
'trajectory.toolbar.collapseTurns': "折叠轮次",
'trajectory.toolbar.hideTools': "隐藏工具",
'trajectory.toolbar.search': "搜索轨迹",
'trajectory.toolbar.clearSearch': "清除搜索",
'trajectory.toolbar.totals': "{turns} 轮 · {steps} 步 · {tools} 次调用 · {tokens} tok",
'trajectory.toolbar.cacheHit': "缓存命中 {percent}%",
'trajectory.subagent.breadcrumb': "子代理轨迹",
'trajectory.subagent.main': "主会话",
'trajectory.subagent.view': "查看子代理轨迹",
'trajectory.tab.overview': "概述",
'trajectory.tab.preview': "预览",
'trajectory.tab.input': "参数",
'trajectory.tab.output': "结果",
'trajectory.tab.raw': "原始内容",
'trajectory.tab.request': "原始请求",
'trajectory.tab.prompt': "系统提示词",
'trajectory.tab.tools': "工具",
'trajectory.tab.context': "内容",
'trajectory.tab.diff': "差异",
'trajectory.detail.label': "轨迹详情",
'trajectory.detail.tabs': "详情分区",
'trajectory.detail.resize': "调整详情面板宽度",
'trajectory.detail.copy': "复制",
'trajectory.detail.loadFailed': "加载失败:{error}",
'trajectory.detail.notLoaded': "该记录中没有此内容",
'trajectory.detail.truncated': "内容过大,只显示开头部分。",
'trajectory.detail.empty': "没有可显示的内容",
'trajectory.detail.time': "时间",
'trajectory.detail.source': "来源",
'trajectory.detail.status': "状态",
'trajectory.detail.model': "模型",
'trajectory.detail.stopReason': "停止原因",
'trajectory.detail.tokens': "Tokens",
'trajectory.detail.tokenBreakdown': "输入 {input} · 缓存读 {cacheRead} · 缓存写 {cacheWrite} · 输出 {output}",
'trajectory.detail.duration': "耗时",
'trajectory.detail.approx': "约 {value}",
'trajectory.detail.issuedBy': "发起方",
'trajectory.detail.locateInChat': "在对话中定位",
'trajectory.detail.thinking': "思考过程",
'trajectory.detail.resultText': "返回给模型的结果",
'trajectory.detail.resultStructured': "结构化结果",
'trajectory.detail.systemPrompt': "系统提示词 · {count} 字符",
'trajectory.detail.systemPromptDiff': "系统提示词变化",
'trajectory.detail.toolsDiff': "工具变化",
'trajectory.detail.toolsAdded': "新增",
'trajectory.detail.toolsRemoved': "移除",
'trajectory.detail.toolsChanged': "修改",
'trajectory.detail.noChanges': "没有变化",
'trajectory.detail.unchangedLines': "{count} 行未变化",
'trajectory.request.closest': "按时间匹配到的最接近的一次捕获调用。请求与对话记录分开保存,因此对应关系是近似的。",
'trajectory.toolbar.durationHint': "小地图按真实耗时铺开,慢的调用更宽",
'trajectory.toolbar.loadedFrom': "自第 {turn} 轮起(已加载部分)",
'trajectory.toolbar.matches': "{count} 条匹配",
'trajectory.toolbar.totalsHint': "统计范围是已加载的记录,不含子代理自己的 token",
'trajectory.table.errorCount': "{count} 次失败",
'trajectory.table.usageTitle': "输入 token(含缓存)↑ / 输出 token ↓",
'trajectory.table.usageShort': "{input}↑ {output}↓",
'trajectory.tool.callEarlier': "(调用记录在更早的历史中)",
'trajectory.tool.resultTooLarge': "结果过大({size}),未加载",
'trajectory.tool.noResult': "无结果",
'trajectory.minimap.label': "轨迹小地图:输入、模型、工具三条泳道",
'trajectory.search.noResults': "没有匹配的记录",
'trajectory.search.noResultsHint': "换个关键词试试。",
'trajectory.search.noResultsPartial': "只搜索了已加载的记录,可以加载更早的记录后再搜。",
'trajectory.detail.back': "返回列表",
'trajectory.detail.images': "包含 {count} 张图片",
'trajectory.request.captureToggle': "记录原始请求(Agent Trace)",
'trajectory.request.captureHint': "开启后,新的模型调用会保存实际发出的请求和收到的响应。",
'trajectory.request.uncaptured': "这条回复发生时没有开启原始请求记录。",
'trajectory.request.unmatched': "没有找到与这条回复对应的调用。",
'trajectory.user.command': "命令",
'trajectory.user.sessionMessage': "会话消息",
'trajectory.context.apiRetry': "API 重试",
'trajectory.context.localCommand': "本地命令",
'trajectory.context.streamRetry': "流式重试",
'trajectory.context.informational': "提示",
'trajectory.context.awaySummary': "离开摘要",
'trajectory.context.scheduledTask': "定时任务",
'trajectory.context.environment': "环境信息",
'trajectory.context.instructions': "指令说明",
'trajectory.context.sessionContext': "会话上下文",
'trajectory.context.permissionRetry': "权限已放行",
'trajectory.context.memorySaved': "已保存记忆",
'trajectory.context.agentsKilled': "已停止代理",
'trajectory.context.modelFallback': "模型回退",
'trajectory.request.endpoint': "请求地址",
'trajectory.request.provider': "服务商",
'trajectory.request.route': "链路",
'trajectory.request.routeDirect': "SDK 直连服务商",
'trajectory.request.routeProxy': "经本地代理转换协议后发往上游",
'trajectory.request.model': "请求模型",
'trajectory.request.upstreamModel': "实际发送的模型",
'trajectory.request.respondedModel': "响应声明的模型",
'trajectory.request.status': "结果",
'trajectory.request.requestId': "请求 ID",
'trajectory.request.purpose': "调用来源",
}
@@ -5,7 +5,6 @@ import type {
DesktopHostUnlisten,
NotificationPermissionState,
} from './types'
import { buildTraceWindowUrl } from '../traceLaunch'
import { readBrowserLanguages } from '../../i18n/locale'
const browserCapabilities: DesktopHostCapabilities = {
@@ -122,15 +121,6 @@ export const browserHost: DesktopHost = {
unsupported('Opening system file paths')
},
},
trace: {
async openWindow(sessionId) {
if (typeof window !== 'undefined') {
window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer')
return
}
unsupported('Opening trace windows')
},
},
pets: {
async list() {
unsupported('Custom pets')
@@ -198,18 +198,6 @@ describe('electron desktop host', () => {
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.windowStartDragging, undefined)
})
it('opens dedicated trace windows through a narrow IPC channel', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({
invoke,
subscribe: vi.fn(),
})
await host.trace?.openWindow('session-123')
expect(invoke).toHaveBeenCalledWith(ELECTRON_IPC_CHANNELS.traceOpenWindow, 'session-123')
})
it('routes preview zoom through the preview IPC channel', async () => {
const invoke = vi.fn().mockResolvedValue(undefined)
const host = createElectronHost({
@@ -119,9 +119,6 @@ export function createElectronHost(bridge: ElectronHostBridge): DesktopHost {
open: target => invoke(ELECTRON_IPC_CHANNELS.shellOpen, target),
openPath: path => invoke(ELECTRON_IPC_CHANNELS.shellOpenPath, path),
},
trace: {
openWindow: sessionId => invoke(ELECTRON_IPC_CHANNELS.traceOpenWindow, sessionId),
},
pets: {
list: () => invoke(ELECTRON_IPC_CHANNELS.petsList),
createFromImage: input => invoke(ELECTRON_IPC_CHANNELS.petsCreateFromImage, input),
-3
View File
@@ -442,9 +442,6 @@ export type DesktopHost = {
open(target: string): Promise<void>
openPath(path: string): Promise<void>
}
trace?: {
openWindow(sessionId: string): Promise<void>
}
pets: {
list(): Promise<DesktopPetListResult>
createFromImage(input: DesktopPetCreateInput): Promise<DesktopPetCreateResult | null>
+4 -3
View File
@@ -21,10 +21,11 @@ describe('formatTokenCount', () => {
it('drops the trailing .0 to match the CLI notation', () => {
expect(formatTokenCount(1000)).toBe('1k')
expect(formatTokenCount(2_000_000)).toBe('2m')
expect(formatTokenCount(2_000_000)).toBe('2M')
})
it('formats millions with an m suffix', () => {
expect(formatTokenCount(2_400_000)).toBe('2.4m')
it('formats millions with an uppercase M so they never read as minutes', () => {
expect(formatTokenCount(2_400_000)).toBe('2.4M')
expect(formatTokenCount(145_900_000)).toBe('145.9M')
})
})
+5 -2
View File
@@ -2,12 +2,15 @@
* Compact token-count formatter shared by the chat UI (session header,
* streaming indicator, compact summary, background agents) and trace views,
* so every surface renders token usage with one notation.
* "847" below 1000, "1.2k" up to 1M, "1.2m" beyond — trailing ".0" dropped
* "847" below 1000, "1.2k" up to 1M, "1.2M" beyond — trailing ".0" dropped
* ("1k", not "1.0k") to match the CLI's formatTokens.
*
* Millions take an uppercase "M": these counts sit next to durations such as
* "52m 43s", where a lowercase "145.9m" reads as minutes.
*/
export function formatTokenCount(n?: number): string {
if (typeof n !== 'number' || !Number.isFinite(n) || n < 0) return '--'
if (n < 1000) return String(Math.round(n))
if (n < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k`
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}m`
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
}
+71 -2
View File
@@ -77,7 +77,6 @@ describe('desktop persistence migrations', () => {
window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
{ sessionId: '__market__', title: 'Market', type: 'market' },
{ sessionId: '__traces__', title: 'Traces', type: 'traces' },
],
activeTabId: '__market__',
}))
@@ -88,12 +87,82 @@ describe('desktop persistence migrations', () => {
expect(JSON.parse(window.localStorage.getItem('cc-haha-open-tabs') || '{}')).toEqual({
openTabs: [
{ sessionId: '__market__', title: 'Market', type: 'market' },
{ sessionId: '__traces__', title: 'Traces', type: 'traces' },
],
activeTabId: '__market__',
})
})
test('upgrades a frozen schema-5 install with retired Trace tabs and Settings entry', () => {
// Exactly what a schema-5 build persisted with the Trace list open, two
// per-session trace tabs (one of whose sessions also has a chat tab), and
// a trace tab focused. The Trace page is gone; traces live in the chat's
// 轨迹 view, so a trace tab must reopen as its session's chat tab.
localStorage.setItem(DESKTOP_PERSISTENCE_VERSION_KEY, '5')
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
{ sessionId: 'session-a', title: 'Chat A', type: 'session' },
{ sessionId: '__traces__', title: 'Traces', type: 'traces' },
{ sessionId: '__trace__session-a', title: 'Chat A', type: 'trace', traceSessionId: 'session-a' },
{ sessionId: '__trace__session-b', title: 'Chat B', type: 'trace', traceSessionId: 'session-b' },
{ sessionId: '__settings__', title: 'Settings', type: 'settings' },
],
activeTabId: '__trace__session-b',
}))
localStorage.setItem('cc-haha-active-settings-tab', 'trace')
const report = runDesktopPersistenceMigrations()
const expected = {
openTabs: [
{ sessionId: 'session-a', title: 'Chat A', type: 'session' },
{ sessionId: 'session-b', title: 'Chat B', type: 'session' },
{ sessionId: '__settings__', title: 'Settings', type: 'settings' },
],
activeTabId: 'session-b',
}
expect(JSON.parse(localStorage.getItem('cc-haha-open-tabs')!)).toEqual(expected)
expect(localStorage.getItem('cc-haha-active-settings-tab')).toBe('general')
expect(report.migratedKeys).toContain('cc-haha-active-settings-tab')
expect(localStorage.getItem(DESKTOP_PERSISTENCE_VERSION_KEY)).toBe(String(CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION))
// Idempotent: a second launch leaves both keys alone.
const second = runDesktopPersistenceMigrations()
expect(JSON.parse(localStorage.getItem('cc-haha-open-tabs')!)).toEqual(expected)
expect(second.migratedKeys).not.toContain('cc-haha-active-settings-tab')
})
test.each([
['the Trace list', '__traces__'],
['an unresolvable trace tab', '__trace__'],
])('falls back to the first surviving tab when %s was focused', (_label, activeTabId) => {
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
{ sessionId: '__traces__', title: 'Traces', type: 'traces' },
{ sessionId: '__trace__', title: 'Broken', type: 'trace' },
{ sessionId: 'session-a', title: 'Chat A', type: 'session' },
],
activeTabId,
}))
runDesktopPersistenceMigrations()
expect(JSON.parse(localStorage.getItem('cc-haha-open-tabs')!)).toEqual({
openTabs: [{ sessionId: 'session-a', title: 'Chat A', type: 'session' }],
activeTabId: 'session-a',
})
})
test('drops the open-tabs key when only retired Trace tabs were open', () => {
localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [{ sessionId: '__traces__', title: 'Traces', type: 'traces' }],
activeTabId: '__traces__',
}))
runDesktopPersistenceMigrations()
expect(localStorage.getItem('cc-haha-open-tabs')).toBeNull()
})
test('canonicalizes mismatched persisted special tab ids and types during startup migration', () => {
window.localStorage.setItem('cc-haha-open-tabs', JSON.stringify({
openTabs: [
+46 -6
View File
@@ -12,7 +12,7 @@ import {
import { CHAT_APPEARANCE_STORAGE_KEY, migrateChatAppearance } from './chatAppearance'
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 5
export const CURRENT_DESKTOP_PERSISTENCE_SCHEMA_VERSION = 6
export const DESKTOP_PERSISTENCE_VERSION_KEY = 'cc-haha.persistence.schemaVersion'
type DesktopMigrationReport = {
@@ -28,14 +28,19 @@ const FOLLOW_SYSTEM_THEME_STORAGE_KEY = 'cc-haha-follow-system-theme'
const LIGHT_THEME_STORAGE_KEY = 'cc-haha-light-theme'
const DARK_THEME_STORAGE_KEY = 'cc-haha-dark-theme'
const LOCALE_STORAGE_KEY = 'cc-haha-locale'
const ACTIVE_SETTINGS_TAB_STORAGE_KEY = 'cc-haha-active-settings-tab'
// Schema 6 retired the standalone Trace page: its list tab, its per-session
// tabs and its Settings rail entry. Per-session traces now live in the chat's
// 轨迹 view, so a trace tab reopens as that session's chat tab.
const LEGACY_TRACE_LIST_TAB_ID = '__traces__'
const LEGACY_TRACE_TAB_PREFIX = '__trace__'
const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max']
const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'market', 'connectors', 'traces'] as const
const PERSISTED_SPECIAL_TAB_TYPES = ['settings', 'scheduled', 'market', 'connectors'] as const
const PERSISTED_SPECIAL_TAB_IDS: Record<(typeof PERSISTED_SPECIAL_TAB_TYPES)[number], string> = {
settings: '__settings__',
scheduled: '__scheduled__',
market: '__market__',
connectors: '__connectors__',
traces: '__traces__',
}
const SUPPORTED_LOCALES = ['en', 'zh', 'zh-TW', 'jp', 'kr']
const WORKSPACE_PERSISTED_TAB_KINDS = ['file', 'browser', 'review', 'terminal']
@@ -59,10 +64,27 @@ function getPersistedSpecialTabType(tab: Record<string, unknown>): (typeof PERSI
if (tab.sessionId === '__scheduled__') return 'scheduled'
if (tab.sessionId === '__connectors__') return 'market'
if (tab.sessionId === '__market__') return 'market'
if (tab.sessionId === '__traces__') return 'traces'
return isPersistedSpecialTabType(tab.type) ? tab.type === 'connectors' ? 'market' : tab.type : null
}
function isLegacyTraceListTab(tab: Record<string, unknown>): boolean {
return tab.sessionId === LEGACY_TRACE_LIST_TAB_ID || tab.type === 'traces'
}
function isLegacyTraceTab(tab: Record<string, unknown>): boolean {
return tab.type === 'trace' || String(tab.sessionId).startsWith(LEGACY_TRACE_TAB_PREFIX)
}
/** The session a retired per-session trace tab pointed at, or null. */
function getLegacyTraceSessionId(tab: Record<string, unknown>): string | null {
if (!isLegacyTraceTab(tab)) return null
const sessionId = String(tab.sessionId)
const traced = typeof tab.traceSessionId === 'string' && tab.traceSessionId
? tab.traceSessionId
: sessionId.slice(LEGACY_TRACE_TAB_PREFIX.length)
return traced && !traced.startsWith(LEGACY_TRACE_TAB_PREFIX) ? traced : null
}
function writeJson(storage: StorageLike, key: string, value: unknown): void {
storage.setItem(key, JSON.stringify(value))
}
@@ -82,10 +104,15 @@ function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void
.filter((tab): tab is Record<string, unknown> => isRecord(tab))
.filter((tab) => typeof tab.sessionId === 'string' && typeof tab.title === 'string')
.filter((tab) => tab.type !== 'terminal' && !String(tab.sessionId).startsWith('__terminal__'))
.filter((tab) => !isLegacyTraceListTab(tab))
.filter((tab) => !isLegacyTraceTab(tab) || getLegacyTraceSessionId(tab) !== null)
.map((tab) => {
const specialType = getPersistedSpecialTabType(tab)
const tracedSessionId = getLegacyTraceSessionId(tab)
return {
sessionId: specialType ? PERSISTED_SPECIAL_TAB_IDS[specialType] : tab.sessionId as string,
sessionId: specialType
? PERSISTED_SPECIAL_TAB_IDS[specialType]
: tracedSessionId ?? tab.sessionId as string,
title: tab.title as string,
type: specialType ?? 'session',
}
@@ -93,7 +120,12 @@ function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void
.filter((tab, index, tabs) => tabs.findIndex(other => other.sessionId === tab.sessionId) === index)
const legacyActive = isRecord(parsed) ? rawTabs.find(tab => isRecord(tab) && tab.sessionId === parsed.activeTabId) : undefined
const activeType = isRecord(legacyActive) ? getPersistedSpecialTabType(legacyActive) : null
const normalizedActive = activeType ? PERSISTED_SPECIAL_TAB_IDS[activeType] : isRecord(parsed) ? parsed.activeTabId : null
const activeTracedSessionId = isRecord(parsed) && typeof parsed.activeTabId === 'string'
? getLegacyTraceSessionId(isRecord(legacyActive) ? legacyActive : { sessionId: parsed.activeTabId })
: null
const normalizedActive = activeType
? PERSISTED_SPECIAL_TAB_IDS[activeType]
: activeTracedSessionId ?? (isRecord(parsed) ? parsed.activeTabId : null)
const activeTabId =
isRecord(parsed) &&
typeof normalizedActive === 'string' &&
@@ -112,6 +144,13 @@ function migrateTabs(storage: StorageLike, report: DesktopMigrationReport): void
report.migratedKeys.push(TAB_STORAGE_KEY)
}
function migrateActiveSettingsTab(storage: StorageLike, report: DesktopMigrationReport): void {
// The Trace rail entry is gone; its capture toggle lives under General.
if (storage.getItem(ACTIVE_SETTINGS_TAB_STORAGE_KEY) !== 'trace') return
storage.setItem(ACTIVE_SETTINGS_TAB_STORAGE_KEY, 'general')
report.migratedKeys.push(ACTIVE_SETTINGS_TAB_STORAGE_KEY)
}
function migrateSessionRuntime(storage: StorageLike, report: DesktopMigrationReport): void {
const raw = storage.getItem(SESSION_RUNTIME_STORAGE_KEY)
if (!raw) return
@@ -314,6 +353,7 @@ export function runDesktopPersistenceMigrations(storage: StorageLike | null = ge
if (!storage) return report
runMigrationStep(report, TAB_STORAGE_KEY, () => migrateTabs(storage, report))
runMigrationStep(report, ACTIVE_SETTINGS_TAB_STORAGE_KEY, () => migrateActiveSettingsTab(storage, report))
runMigrationStep(report, SESSION_RUNTIME_STORAGE_KEY, () => migrateSessionRuntime(storage, report))
runMigrationStep(report, THEME_STORAGE_KEY, () =>
migrateThemeKey(storage, THEME_STORAGE_KEY, THEME_MODES, report))
+3 -1
View File
@@ -11,6 +11,8 @@ export async function fetchTraceCallDetail(
sessionId: string,
callId: string,
revisionKey?: string,
/** Record locator from `/trace/near`; reaches calls outside the indexed window. */
at?: string,
): Promise<TraceCallRecord | null> {
// revisionKey is the call's own content, not the session revision. Callers pass
// the same key for an unchanged call, so a sibling span does not bust this cache.
@@ -26,7 +28,7 @@ export async function fetchTraceCallDetail(
if (existingKey.startsWith(prefix)) removeCachedCall(existingKey)
}
try {
const result = await sessionsApi.getTraceCall(sessionId, callId)
const result = await sessionsApi.getTraceCall(sessionId, callId, at)
const call = result?.call
if (!call) return null
const bytes = estimateRetainedBytes(call)
+24
View File
@@ -25,3 +25,27 @@ export function formatClockTime(iso: string): string {
if (Number.isNaN(date.getTime())) return iso
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })
}
/** Pretty-print a JSON string or value; a non-JSON string passes through unchanged. */
export function formatTraceJson(value: unknown): string {
if (typeof value === 'string') {
const parsed = parseJsonText(value)
return parsed === null ? value : JSON.stringify(parsed, null, 2)
}
try {
// JSON.stringify(undefined) yields undefined, not a string.
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
function parseJsonText(value: string): unknown | null {
const trimmed = value.trim()
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null
try {
return JSON.parse(trimmed)
} catch {
return null
}
}
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest'
import type { TraceCallRecord } from '../../types/trace'
import { summarizeTraceCall } from './requestSummary'
function call(overrides: Partial<TraceCallRecord> & { body?: unknown; preview?: string } = {}): TraceCallRecord {
const { body, preview, ...rest } = overrides
return {
id: 'c1',
sessionId: 's1',
source: 'anthropic',
status: 'ok',
startedAt: '2026-10-04T10:00:00.000Z',
durationMs: 1234,
model: 'claude-opus-5-5',
request: {
method: 'POST',
url: 'https://api.anthropic.com/v1/messages?beta=true',
headers: {},
body: { contentType: 'json', bytes: 10, sha256: 'x', preview: preview ?? '', truncated: false },
...(body !== undefined ? { semantic: { version: 1 as const, request: body as Record<string, unknown> } } : {}),
},
response: { status: 200, headers: { 'request-id': 'req_011abc' }, body: { contentType: 'json', bytes: 1, sha256: 'y', preview: '', truncated: false } },
...rest,
}
}
describe('summarizeTraceCall', () => {
it('reports a direct call: endpoint, model, status, duration and request id', () => {
const summary = summarizeTraceCall(call({ body: { model: 'claude-opus-5-5' }, querySource: 'sdk', provider: { id: null, name: 'Anthropic', format: 'anthropic' } }), null)
expect(summary).toMatchObject({
method: 'POST',
url: 'https://api.anthropic.com/v1/messages?beta=true',
route: 'direct',
providerName: 'Anthropic',
providerFormat: 'anthropic',
requestedModel: 'claude-opus-5-5',
status: 200,
durationMs: 1234,
requestId: 'req_011abc',
querySource: 'sdk',
})
expect(summary.upstreamModel).toBeUndefined()
})
it('shows the model the proxy actually sent upstream when it was mapped', () => {
const summary = summarizeTraceCall(call({
source: 'proxy',
request: {
method: 'POST',
url: 'https://api.deepseek.com/chat/completions',
headers: {},
body: { contentType: 'json', bytes: 10, sha256: 'x', preview: JSON.stringify({ anthropic: { model: 'claude-sonnet-5-5' }, upstream: { model: 'deepseek-v4-flash' } }), truncated: false },
},
}), { kind: 'sse', message: null, usage: null, model: 'deepseek-v4-flash-0915' })
expect(summary).toMatchObject({
route: 'proxy',
url: 'https://api.deepseek.com/chat/completions',
requestedModel: 'claude-sonnet-5-5',
upstreamModel: 'deepseek-v4-flash',
respondedModel: 'deepseek-v4-flash-0915',
})
})
it('omits a responded model that only repeats what was sent', () => {
const summary = summarizeTraceCall(call({ body: { model: 'claude-opus-5-5' } }), { kind: 'json', message: null, usage: null, model: 'claude-opus-5-5' })
expect(summary.respondedModel).toBeUndefined()
})
it('still finds the model in a truncated body preview', () => {
const summary = summarizeTraceCall(call({ model: undefined, preview: '{"model":"glm-5","messages":[{"role":"user","content":"…' }), null)
expect(summary.requestedModel).toBe('glm-5')
})
it('ignores a redacted request id header', () => {
const summary = summarizeTraceCall(call({
response: { status: 200, headers: { 'X-Request-Id': '[REDACTED]', 'cf-ray': '8f1a2b' }, body: { contentType: 'json', bytes: 1, sha256: 'y', preview: '', truncated: false } },
}), null)
expect(summary.requestId).toBe('8f1a2b')
})
})
+80
View File
@@ -0,0 +1,80 @@
import type { TraceCallRecord } from '../../types/trace'
import type { ParsedResponse } from './requestParse'
/** What a reader wants first about one captured model call. */
export type TraceRequestSummary = {
method?: string
url?: string
/** `direct` = the SDK called the provider; `proxy` = the local proxy translated the protocol. */
route: 'direct' | 'proxy'
providerName?: string
providerFormat?: string
/** Model id the harness asked for. */
requestedModel?: string
/** Model id actually sent upstream, when the proxy mapped it to something else. */
upstreamModel?: string
/** Model id the provider reported in its response, when it differs from what was sent. */
respondedModel?: string
status?: number
durationMs?: number
startedAt: string
requestId?: string
querySource?: string
}
const REQUEST_ID_HEADERS = ['request-id', 'x-request-id', 'x-amzn-requestid', 'x-ms-request-id', 'cf-ray']
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function modelOf(value: unknown): string | undefined {
return isRecord(value) && typeof value.model === 'string' && value.model ? value.model : undefined
}
function requestBody(call: TraceCallRecord): unknown {
if (call.request.semantic?.request) return call.request.semantic.request
const preview = call.request.body.preview
if (!preview) return undefined
try {
return JSON.parse(preview)
} catch {
// A truncated preview is not JSON; the model id is usually near the top.
const match = /"model"\s*:\s*"([^"]+)"/.exec(preview)
return match ? { model: match[1] } : undefined
}
}
function headerValue(headers: Record<string, string> | undefined, names: readonly string[]): string | undefined {
if (!headers) return undefined
const lower = new Map(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
for (const name of names) {
const value = lower.get(name)
if (value && !/redacted/i.test(value)) return value
}
return undefined
}
export function summarizeTraceCall(call: TraceCallRecord, response: ParsedResponse | null): TraceRequestSummary {
const body = requestBody(call)
// The proxy records both sides of its translation: `{ anthropic, upstream }`.
const proxied = isRecord(body) && 'anthropic' in body && 'upstream' in body
const requestedModel = (proxied ? modelOf(body.anthropic) : modelOf(body)) ?? call.model
const sentModel = proxied ? modelOf(body.upstream) : requestedModel
const respondedModel = response?.model
return {
method: call.request.method || undefined,
url: call.request.url || undefined,
route: call.source === 'proxy' || proxied ? 'proxy' : 'direct',
providerName: call.provider?.name || undefined,
providerFormat: call.provider?.format || undefined,
requestedModel,
...(sentModel && sentModel !== requestedModel ? { upstreamModel: sentModel } : {}),
...(respondedModel && respondedModel !== (sentModel ?? requestedModel) ? { respondedModel } : {}),
status: call.response?.status,
durationMs: call.durationMs,
startedAt: call.startedAt,
requestId: headerValue(call.response?.headers, REQUEST_ID_HEADERS),
querySource: call.querySource,
}
}
-27
View File
@@ -1,27 +0,0 @@
import { describe, expect, it } from 'vitest'
import { buildTraceWindowUrl, getTraceLaunchRequest } from './traceLaunch'
describe('trace launch URLs', () => {
it('parses dedicated trace window requests by session id', () => {
expect(getTraceLaunchRequest('?traceWindow=1&traceSessionId=session-123')).toEqual({
sessionId: 'session-123',
windowMode: true,
})
})
it('accepts sessionId as a shorter deep-link alias', () => {
expect(getTraceLaunchRequest('?sessionId=session-456')).toEqual({
sessionId: 'session-456',
windowMode: false,
})
})
it('builds a same-app trace window URL without dropping existing connection params', () => {
expect(buildTraceWindowUrl(
'session-789',
'http://127.0.0.1:5173/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456',
)).toBe(
'http://127.0.0.1:5173/?serverUrl=http%3A%2F%2F127.0.0.1%3A3456&traceWindow=1&traceSessionId=session-789',
)
})
})
-30
View File
@@ -1,30 +0,0 @@
export type TraceLaunchRequest = {
sessionId: string | null
windowMode: boolean
}
function normalizeTraceSessionId(value: string | null): string | null {
const trimmed = value?.trim()
return trimmed ? trimmed : null
}
function isTruthyParam(value: string | null): boolean {
return value === '1' || value === 'true' || value === 'yes'
}
export function getTraceLaunchRequest(search = window.location.search): TraceLaunchRequest {
const params = new URLSearchParams(search)
return {
sessionId: normalizeTraceSessionId(
params.get('traceSessionId') ?? params.get('sessionId'),
),
windowMode: isTruthyParam(params.get('traceWindow')),
}
}
export function buildTraceWindowUrl(sessionId: string, currentHref = window.location.href): string {
const url = new URL(currentHref)
url.searchParams.set('traceWindow', '1')
url.searchParams.set('traceSessionId', sessionId)
return url.toString()
}
-87
View File
@@ -1,87 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
openTraceCaptureSettings,
openTraceDetail,
openTraceList,
returnToTraceList,
} from './traceNavigation'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
import { useSettingsStore } from '../stores/settingsStore'
vi.mock('../api/sessions', () => ({
sessionsApi: { list: vi.fn() },
}))
describe('traceNavigation', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
})
afterEach(() => {
useTabStore.setState({ tabs: [], activeTabId: null })
useUIStore.setState({ pendingSettingsTab: null })
})
it('opens the trace list by focusing the Settings tab on its Trace section', () => {
openTraceList()
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === SETTINGS_TAB_ID)?.type).toBe('settings')
expect(useUIStore.getState().pendingSettingsTab).toBe('trace')
})
it('sends the capture switches to General, not to the list', () => {
openTraceCaptureSettings()
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useUIStore.getState().pendingSettingsTab).toBe('general')
})
it('titles a detail tab with the session alone, no trace prefix', () => {
const tabId = openTraceDetail('session-9', 'Debug stuck agent')
expect(tabId).toBe('__trace__session-9')
const tab = useTabStore.getState().tabs.find((entry) => entry.sessionId === tabId)
expect(tab?.title).toBe('Debug stuck agent')
expect(tab?.traceSessionId).toBe('session-9')
})
it('lands on the list and closes the detail tab it came from', () => {
const tabId = openTraceDetail('session-9', 'Debug stuck agent')
returnToTraceList(tabId)
expect(useTabStore.getState().tabs.some((tab) => tab.sessionId === tabId)).toBe(false)
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useUIStore.getState().pendingSettingsTab).toBe('trace')
})
it('does not strand focus on a neighbouring tab when closing the detail tab', () => {
// Closing the active tab hands focus to whichever tab takes its index, so
// the return has to switch away first. With a chat tab sitting where the
// trace tab was, a close-then-switch order would land on the chat instead.
useTabStore.setState({
tabs: [{ sessionId: 'chat-1', title: 'Chat', type: 'session', status: 'idle' }],
activeTabId: 'chat-1',
})
const tabId = openTraceDetail('session-9', 'Debug stuck agent')
returnToTraceList(tabId)
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['chat-1', SETTINGS_TAB_ID])
})
it('reuses the existing detail tab when the same trace is reopened', () => {
const first = openTraceDetail('session-9', 'Debug stuck agent')
useTabStore.getState().setActiveTab(SETTINGS_TAB_ID)
const second = openTraceDetail('session-9', 'Debug stuck agent renamed')
expect(second).toBe(first)
expect(useTabStore.getState().tabs.filter((tab) => tab.type === 'trace')).toHaveLength(1)
expect(useTabStore.getState().activeTabId).toBe(first)
})
})
-37
View File
@@ -1,37 +0,0 @@
import { t } from '../i18n'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useUIStore } from '../stores/uiStore'
/**
* The trace list lives inside Settings, so "show the list" and "go back to the
* list" are the same action: focus the Settings tab and select its Trace
* section. Routing every entry point through here keeps the return path one hop
* regardless of where the detail tab was opened from — the list, a deep link,
* or a restored tab.
*/
export function openTraceList(): void {
useUIStore.getState().setPendingSettingsTab('trace')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** The capture switches themselves live one section over, under General. */
export function openTraceCaptureSettings(): void {
useUIStore.getState().setPendingSettingsTab('general')
useTabStore.getState().openTab(SETTINGS_TAB_ID, t('sidebar.settings'), 'settings')
}
/** Drill into one session's trace. The tab is titled with the session it traces. */
export function openTraceDetail(sessionId: string, title: string): string {
return useTabStore.getState().openTraceTab(sessionId, title)
}
/**
* Leave a detail tab the way `returnFromWorkbench` leaves a workbench tab: land
* on the list first, then drop the tab we came from, so browsing several traces
* in a row does not leave a trail of dead tabs behind. Ordering matters — the
* close runs after the switch so the tab bar never flashes an interim tab.
*/
export function returnToTraceList(tabId: string): void {
openTraceList()
useTabStore.getState().closeTab(tabId)
}
-333
View File
@@ -1,333 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { buildTraceViewModel } from './traceViewModel'
import type { MessageEntry } from '../types/session'
import type { TraceSession } from '../types/trace'
const trace: TraceSession = {
sessionId: 'session-live',
session: null,
summary: {
apiCalls: 2,
failedCalls: 0,
totalDurationMs: 1700,
totalInputTokens: 12,
totalOutputTokens: 18,
models: [{ model: 'gpt-5.5', calls: 2 }],
updatedAt: '2026-06-09T10:00:04.000Z',
},
calls: [
{
id: 'call-1',
sessionId: 'session-live',
source: 'anthropic',
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
startedAt: '2026-06-09T10:00:01.000Z',
completedAt: '2026-06-09T10:00:02.000Z',
durationMs: 1000,
usage: { inputTokens: 12, outputTokens: 18, cacheReadInputTokens: 4 },
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: {},
body: { contentType: 'json', bytes: 20, sha256: 'a', preview: '{"model":"gpt-5.5"}', truncated: false },
},
response: {
status: 200,
headers: {},
body: { contentType: 'json', bytes: 11, sha256: 'b', preview: '{"ok":true}', truncated: false },
},
},
{
id: 'call-2',
sessionId: 'session-live',
source: 'anthropic',
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
startedAt: '2026-06-09T10:00:04.000Z',
completedAt: '2026-06-09T10:00:04.700Z',
durationMs: 700,
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: {},
body: { contentType: 'json', bytes: 20, sha256: 'c', preview: '{"model":"gpt-5.5"}', truncated: false },
},
response: {
status: 200,
headers: {},
body: { contentType: 'json', bytes: 11, sha256: 'd', preview: '{"ok":true}', truncated: false },
},
},
],
events: [
{
id: 'event-1',
sessionId: 'session-live',
callId: 'call-1',
source: 'anthropic',
provider: { id: 'provider-sub2api', name: 'Sub2API-ChatGPT', format: 'anthropic' },
model: 'gpt-5.5',
timestamp: '2026-06-09T10:00:01.100Z',
phase: 'api_call_started',
severity: 'info',
metadata: { url: 'https://sub2api.example/v1/messages' },
},
],
}
const messages: MessageEntry[] = [
{
id: 'user-1',
type: 'user',
content: 'Run ls and summarize the result',
timestamp: '2026-06-09T10:00:00.000Z',
},
{
id: 'assistant-1',
type: 'tool_use',
content: [
{ type: 'text', text: 'I will inspect the directory.' },
{ type: 'tool_use', id: 'tool-1', name: 'Bash', input: { command: 'ls -la /tmp' } },
],
timestamp: '2026-06-09T10:00:02.000Z',
},
{
id: 'result-1',
type: 'tool_result',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'total 8', is_error: false },
],
timestamp: '2026-06-09T10:00:03.000Z',
},
]
describe('traceViewModel', () => {
it('builds a turn-centered tree with llm and paired tool spans', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.turns).toHaveLength(1)
expect(viewModel.spansById.get('turn:0')?.childIds).toEqual(expect.arrayContaining([
'message:user-1',
'message:assistant-1',
'tool:tool-1',
'llm:call-1',
'llm:call-2',
]))
const tool = viewModel.spansById.get('tool:tool-1')
expect(tool).toMatchObject({
kind: 'tool',
status: 'ok',
title: 'Bash',
subtitle: 'ls -la /tmp',
completedAt: '2026-06-09T10:00:03.000Z',
durationMs: 1000,
})
expect(tool?.childIds[0]).toMatch(/^tool_result:/)
expect(viewModel.spansById.get(tool?.childIds[0] ?? '')).toMatchObject({
kind: 'tool_result',
status: 'ok',
output: 'total 8',
})
expect(viewModel.spansById.get('event:event-1')).toMatchObject({
kind: 'event',
parentId: 'llm:call-1',
status: 'ok',
})
expect(viewModel.spansById.get('llm:call-1')?.childIds).toContain('event:event-1')
})
it('calculates wall-clock timing for turns and the session from span end times', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.spansById.get('turn:0')).toMatchObject({
completedAt: '2026-06-09T10:00:04.700Z',
durationMs: 4700,
})
expect(viewModel.spansById.get(viewModel.rootId)).toMatchObject({
completedAt: '2026-06-09T10:00:04.700Z',
durationMs: 4700,
})
expect(viewModel.diagnosis.lastActivityAt).toBe('2026-06-09T10:00:04.700Z')
})
it('shows elapsed time for pending tools without marking them completed', () => {
const viewModel = buildTraceViewModel(trace, messages.slice(0, 2), {
now: '2026-06-09T10:00:07.000Z',
})
expect(viewModel.spansById.get('tool:tool-1')).toMatchObject({
status: 'pending',
durationMs: 5000,
})
expect(viewModel.spansById.get('tool:tool-1')?.completedAt).toBeUndefined()
expect(viewModel.spansById.get('turn:0')).toMatchObject({
status: 'pending',
durationMs: 7000,
})
expect(viewModel.spansById.get(viewModel.rootId)).toMatchObject({
status: 'pending',
durationMs: 7000,
})
expect(viewModel.diagnosis.lastActivityAt).toBe('2026-06-09T10:00:07.000Z')
})
it('passes call usage through to llm spans as tokenUsage', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.spansById.get('llm:call-1')?.tokenUsage).toEqual({
inputTokens: 12,
outputTokens: 18,
cacheReadInputTokens: 4,
})
expect(viewModel.spansById.get('llm:call-2')?.tokenUsage).toBeUndefined()
})
it('marks info lifecycle events as noise and omits fullRaw', () => {
const viewModel = buildTraceViewModel(trace, messages)
expect(viewModel.spansById.get('event:event-1')?.isLifecycleNoise).toBe(true)
expect(viewModel.spansById.get('llm:call-1')?.isLifecycleNoise).toBeUndefined()
expect('fullRaw' in viewModel).toBe(false)
})
it('marks pending tool spans when a result has not arrived', () => {
const viewModel = buildTraceViewModel(trace, messages.slice(0, 2))
expect(viewModel.spansById.get('tool:tool-1')).toMatchObject({ status: 'pending' })
expect(viewModel.spansById.get('turn:0')).toMatchObject({ status: 'pending' })
expect(viewModel.diagnosis).toMatchObject({
status: 'attention',
reason: 'pending_tool',
focusSpanId: 'tool:tool-1',
pendingToolCalls: 1,
})
})
it('diagnoses event-only errors as blocked', () => {
const viewModel = buildTraceViewModel({
sessionId: 'session-event-error',
session: null,
summary: {
apiCalls: 0,
failedCalls: 0,
totalDurationMs: 0,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [],
updatedAt: null,
},
calls: [],
events: [{
id: 'event-failed',
sessionId: 'session-event-error',
timestamp: '2026-06-09T10:00:02.000Z',
phase: 'upstream_fetch_failed',
severity: 'error',
message: 'network down',
}],
}, [])
expect(viewModel.spansById.get('event:event-failed')).toMatchObject({
kind: 'event',
status: 'error',
title: 'Upstream Fetch Failed',
isLifecycleNoise: false,
})
expect(viewModel.diagnosis).toMatchObject({
status: 'blocked',
reason: 'event_error',
focusSpanId: 'event:event-failed',
})
})
it('surfaces aborted calls as model errors instead of pending', () => {
const viewModel = buildTraceViewModel({
sessionId: 'session-aborted',
session: null,
summary: {
apiCalls: 1,
failedCalls: 1,
totalDurationMs: 240_000,
totalInputTokens: 0,
totalOutputTokens: 0,
models: [{ model: 'gpt-5.5', calls: 1 }],
updatedAt: '2026-06-09T10:04:01.000Z',
},
calls: [{
id: 'call-aborted',
sessionId: 'session-aborted',
source: 'anthropic',
model: 'gpt-5.5',
status: 'error',
startedAt: '2026-06-09T10:00:01.000Z',
completedAt: '2026-06-09T10:04:01.000Z',
durationMs: 240_000,
metadata: { phase: 'api_call_aborted', aborted: true },
request: {
method: 'POST',
url: 'https://sub2api.example/v1/messages',
headers: {},
body: { contentType: 'json', bytes: 20, sha256: 'a', preview: '{"model":"gpt-5.5"}', truncated: false },
},
response: {
status: 200,
headers: {},
body: { contentType: 'text', bytes: 30, sha256: 'b', preview: 'data: {"type":"message_start"}', truncated: true },
},
error: {
name: 'AbortError',
message: 'Stream idle timeout: no chunks received for 240s',
},
}],
events: [{
id: 'event-aborted',
sessionId: 'session-aborted',
callId: 'call-aborted',
timestamp: '2026-06-09T10:04:01.000Z',
phase: 'api_call_aborted',
severity: 'error',
message: 'Stream idle timeout: no chunks received for 240s',
}],
}, [])
const llmSpan = viewModel.spansById.get('llm:call-aborted')
expect(llmSpan).toMatchObject({ kind: 'llm', status: 'error', durationMs: 240_000 })
expect(llmSpan?.completedAt).toBe('2026-06-09T10:04:01.000Z')
expect(viewModel.diagnosis).toMatchObject({
status: 'blocked',
reason: 'model_error',
focusSpanId: 'llm:call-aborted',
pendingModelCalls: 0,
})
})
})
describe('large trace turn lookup', () => {
it('does not rescan all turn timestamps for each history message', () => {
const messages: MessageEntry[] = Array.from({ length: 1000 }, (_, index) => ({
id: `user-${index}`, type: 'user', content: `Turn ${index}`,
timestamp: new Date(1_700_000_000_000 + index * 1000).toISOString(),
}))
const getTime = vi.spyOn(Date.prototype, 'getTime')
try {
const result = buildTraceViewModel({ ...trace, calls: [], events: [] }, messages)
expect(result.spansById.get('message:user-999')?.turnIndex).toBe(999)
// The previous quadratic lookup exceeded a million conversions.
expect(getTime.mock.calls.length).toBeLessThan(100_000)
} finally {
getTime.mockRestore()
}
})
it('preserves the last matching turn for out-of-order and duplicate timestamps', () => {
const messages: MessageEntry[] = [3, 1, 2, 2].map((second, index) => ({
id: `user-${index}`, type: 'user', content: `Turn ${index}`,
timestamp: `2026-06-09T10:00:0${second}.000Z`,
}))
messages.push({ id: 'reply', type: 'assistant', content: 'Reply', timestamp: '2026-06-09T10:00:02.500Z' })
const result = buildTraceViewModel({ ...trace, calls: [], events: [] }, messages)
expect(result.spansById.get('message:reply')?.turnIndex).toBe(3)
})
})
-793
View File
@@ -1,793 +0,0 @@
import type { MessageEntry } from '../types/session'
import type { TraceCallRecord, TraceCallUsage, TraceEventRecord, TraceSession as TraceSessionData } from '../types/trace'
export type TraceSpanKind = 'session' | 'turn' | 'message' | 'llm' | 'tool' | 'tool_result' | 'event'
export type TraceSpanStatus = 'ok' | 'error' | 'pending'
export type TraceSpan = {
id: string
parentId: string | null
childIds: string[]
kind: TraceSpanKind
status: TraceSpanStatus
title: string
subtitle: string
timestamp: string
completedAt?: string
durationMs?: number
turnIndex?: number
message?: MessageEntry
call?: TraceCallRecord
event?: TraceEventRecord
toolUseId?: string
toolName?: string
input?: unknown
output?: unknown
isSidechain?: boolean
tokenUsage?: TraceCallUsage
isLifecycleNoise?: boolean
raw: unknown
}
export type TraceDiagnosisReason =
| 'empty'
| 'model_error'
| 'tool_error'
| 'event_error'
| 'pending_model'
| 'pending_tool'
| 'waiting_for_agent'
| 'healthy'
export type TraceDiagnosis = {
status: 'empty' | 'healthy' | 'attention' | 'blocked'
reason: TraceDiagnosisReason
focusSpanId?: string
evidenceSpanIds: string[]
lastActivityAt: string
errorCount: number
pendingModelCalls: number
pendingToolCalls: number
modelCalls: number
toolCalls: number
}
export type TraceTurn = {
id: string
index: number
title: string
timestamp: string
spanIds: string[]
userSpanId?: string
}
export type TraceViewModel = {
rootId: string
spans: TraceSpan[]
spansById: Map<string, TraceSpan>
turns: TraceTurn[]
orderedSpanIds: string[]
diagnosis: TraceDiagnosis
}
export type TraceViewModelOptions = {
now?: string | number | Date
}
type ToolUseBlock = {
type: 'tool_use'
id?: string
name?: string
input?: unknown
}
type ToolResultBlock = {
type: 'tool_result'
tool_use_id?: string
content?: unknown
is_error?: boolean
}
type TextBlock = {
type?: string
text?: string
}
type MutableSpan = Omit<TraceSpan, 'childIds'> & { childIds: string[] }
type ToolResultOccurrence = {
message: MessageEntry
block: ToolResultBlock
}
export function buildTraceViewModel(
trace: TraceSessionData,
messages: MessageEntry[],
options: TraceViewModelOptions = {},
): TraceViewModel {
const spans = new Map<string, MutableSpan>()
const turns: TraceTurn[] = []
const rootId = 'session:root'
const traceEvents = trace.events ?? []
const nowTimestamp = normalizeNowTimestamp(options.now)
const fallbackTimestamp = earliestTimestamp(trace.calls, messages, traceEvents) ?? new Date().toISOString()
const hasPendingCalls = trace.calls.some((call) => getCallStatus(call) === 'pending')
const hasTraceErrors = trace.summary.failedCalls > 0 || traceEvents.some((event) => event.severity === 'error')
addSpan(spans, {
id: rootId,
parentId: null,
kind: 'session',
status: hasTraceErrors ? 'error' : hasPendingCalls ? 'pending' : 'ok',
title: trace.session?.title ?? trace.sessionId,
subtitle: `${trace.summary.apiCalls} model calls`,
timestamp: fallbackTimestamp,
completedAt: trace.summary.updatedAt ?? undefined,
raw: {
sessionId: trace.sessionId,
summary: trace.summary,
session: trace.session,
events: traceEvents,
},
})
const userTurnStarts = messages.filter((message) => message.type === 'user' && !hasToolResultBlocks(message.content))
if (userTurnStarts.length === 0) {
turns.push(createTurn(0, fallbackTimestamp, 'Session activity'))
} else {
userTurnStarts.forEach((message, index) => {
turns.push(createTurn(index, message.timestamp, createTurnTitle(index, message.content)))
})
}
for (const turn of turns) {
addSpan(spans, {
id: turn.id,
parentId: rootId,
kind: 'turn',
status: 'ok',
title: turn.title,
subtitle: `Turn ${turn.index + 1}`,
timestamp: turn.timestamp,
turnIndex: turn.index,
raw: { index: turn.index, timestamp: turn.timestamp, title: turn.title },
})
}
const findTurn = createTurnLookup(turns)
const toolSpanIds = new Map<string, string>()
const resultBlocksByToolUseId = new Map<string, ToolResultOccurrence[]>()
const deferredToolResultSpans: Array<{ parentId: string; message: MessageEntry; block: ToolResultBlock }> = []
for (const message of messages) {
const turn = findTurn(message.timestamp)
const turnId = turn.id
if (message.type === 'tool_result') {
const resultBlocks = extractToolResultBlocks(message.content)
if (resultBlocks.length === 0) {
const spanId = `message:${message.id}`
addMessageSpan(spans, spanId, turnId, turn.index, message)
turn.spanIds.push(spanId)
continue
}
for (const block of resultBlocks) {
if (block.tool_use_id) {
const existing = resultBlocksByToolUseId.get(block.tool_use_id) ?? []
existing.push({ message, block })
resultBlocksByToolUseId.set(block.tool_use_id, existing)
}
const toolParentId = block.tool_use_id ? toolSpanIds.get(block.tool_use_id) : undefined
if (toolParentId) {
const spanId = `tool_result:${message.id}:${block.tool_use_id ?? resultBlocks.indexOf(block)}`
addToolResultSpan(spans, spanId, toolParentId, turn.index, message, block)
turn.spanIds.push(spanId)
} else {
deferredToolResultSpans.push({ parentId: turnId, message, block })
}
}
continue
}
const text = extractTextContent(message.content)
if (message.type !== 'tool_use' || text.trim()) {
const spanId = `message:${message.id}`
addMessageSpan(spans, spanId, turnId, turn.index, message)
turn.spanIds.push(spanId)
if (message.type === 'user' && !hasToolResultBlocks(message.content)) {
turn.userSpanId = spanId
}
}
if (message.type === 'tool_use') {
for (const block of extractToolUseBlocks(message.content)) {
const toolId = block.id ?? `${message.id}:${block.name ?? 'tool'}`
const parentToolId = message.parentToolUseId ? toolSpanIds.get(message.parentToolUseId) : undefined
const parentId = parentToolId ?? turnId
const spanId = `tool:${toolId}`
toolSpanIds.set(toolId, spanId)
const resultBlocks = block.id ? resultBlocksByToolUseId.get(block.id) ?? [] : []
const completion = latestToolResultTimestamp(message.timestamp, resultBlocks)
const status = resultBlocks.some((result) => result.block.is_error)
? 'error'
: resultBlocks.length > 0
? 'ok'
: 'pending'
addSpan(spans, {
id: spanId,
parentId,
kind: 'tool',
status,
title: block.name ?? 'Tool call',
subtitle: summarizeToolInput(block.input),
timestamp: message.timestamp,
...spanTimingFromTimestamps(message.timestamp, completion ?? (status === 'pending' ? nowTimestamp : undefined), {
completed: Boolean(completion),
}),
turnIndex: turn.index,
message,
toolUseId: toolId,
toolName: block.name,
input: block.input,
output: resultBlocks.map((result) => result.block.content),
isSidechain: message.isSidechain,
raw: block,
})
turn.spanIds.push(spanId)
}
}
}
for (const item of deferredToolResultSpans) {
const turn = findTurn(item.message.timestamp)
const spanId = `tool_result:${item.message.id}:${item.block.tool_use_id ?? turn.spanIds.length}`
addToolResultSpan(spans, spanId, item.parentId, turn.index, item.message, item.block)
turn.spanIds.push(spanId)
}
for (const call of trace.calls) {
const turn = findTurn(call.startedAt)
const spanId = `llm:${call.id}`
const status = getCallStatus(call)
const derivedCallTiming = call.durationMs !== undefined
? {
...(call.completedAt ? { completedAt: call.completedAt } : {}),
durationMs: call.durationMs,
}
: spanTimingFromTimestamps(call.startedAt, call.completedAt ?? (status === 'pending' ? nowTimestamp : undefined), {
completed: Boolean(call.completedAt),
})
addSpan(spans, {
id: spanId,
parentId: turn.id,
kind: 'llm',
status,
title: call.model ?? call.provider?.name ?? 'Model call',
subtitle: call.provider?.name ?? call.source,
timestamp: call.startedAt,
...derivedCallTiming,
turnIndex: turn.index,
call,
tokenUsage: call.usage,
raw: call,
})
turn.spanIds.push(spanId)
}
for (const event of traceEvents) {
const turn = findTurn(event.timestamp)
const callParentId = event.callId ? `llm:${event.callId}` : undefined
const parentId = callParentId && spans.has(callParentId) ? callParentId : turn.id
const spanId = `event:${event.id}`
addSpan(spans, {
id: spanId,
parentId,
kind: 'event',
status: getEventStatus(event),
title: event.title ?? formatTraceEventPhase(event.phase),
subtitle: event.message ?? previewTraceValue(event.metadata),
timestamp: event.timestamp,
turnIndex: turn.index,
event,
isLifecycleNoise: isLifecycleNoiseEvent(event),
raw: event,
})
turn.spanIds.push(spanId)
}
for (const turn of turns) {
const childStatuses = turn.spanIds
.map((spanId) => spans.get(spanId)?.status)
.filter(Boolean)
const turnSpan = spans.get(turn.id)
if (turnSpan) {
turnSpan.status = childStatuses.includes('error')
? 'error'
: childStatuses.includes('pending')
? 'pending'
: 'ok'
turnSpan.subtitle = `${turn.spanIds.length} spans`
applyAggregateTiming(turnSpan, turn.spanIds.map((spanId) => spans.get(spanId)).filter(Boolean) as MutableSpan[])
}
}
const rootSpan = spans.get(rootId)
if (rootSpan) {
const childStatuses = rootSpan.childIds
.map((spanId) => spans.get(spanId)?.status)
.filter(Boolean)
rootSpan.status = childStatuses.includes('error')
? 'error'
: childStatuses.includes('pending')
? 'pending'
: rootSpan.status
applyAggregateTiming(rootSpan, rootSpan.childIds.map((spanId) => spans.get(spanId)).filter(Boolean) as MutableSpan[])
}
const orderedSpanIds = orderSpans(spans, rootId)
const spanList = orderedSpanIds.map((id) => spans.get(id)).filter(Boolean) as TraceSpan[]
return {
rootId,
spans: spanList,
spansById: new Map(spanList.map((span) => [span.id, span])),
turns,
orderedSpanIds,
diagnosis: buildDiagnosis(spanList, turns, rootId, fallbackTimestamp),
}
}
function buildDiagnosis(
spans: TraceSpan[],
turns: TraceTurn[],
rootId: string,
fallbackTimestamp: string,
): TraceDiagnosis {
const meaningfulSpans = spans.filter((span) => span.id !== rootId)
const errorSpans = meaningfulSpans.filter((span) => span.status === 'error')
const pendingModels = meaningfulSpans.filter((span) => span.kind === 'llm' && span.status === 'pending')
const pendingTools = meaningfulSpans.filter((span) => span.kind === 'tool' && span.status === 'pending')
const toolErrors = errorSpans.filter((span) => span.kind === 'tool' || span.kind === 'tool_result')
const modelErrors = errorSpans.filter((span) => span.kind === 'llm')
const eventErrors = errorSpans.filter((span) => span.kind === 'event')
const lastSpan = meaningfulSpans
.slice()
.sort((a, b) => compareSpanActivityTime(a, b))
.at(-1)
const lastTurn = turns.at(-1)
const lastTurnIds = new Set(lastTurn?.spanIds ?? [])
const lastTurnSpans = spans.filter((span) => lastTurnIds.has(span.id))
const lastTurnHasUser = lastTurnSpans.some((span) => span.message?.type === 'user')
const lastTurnHasAgentWork = lastTurnSpans.some((span) =>
span.kind === 'llm' ||
span.kind === 'tool' ||
span.kind === 'tool_result' ||
span.message?.type === 'assistant' ||
span.message?.type === 'tool_use'
)
if (meaningfulSpans.length === 0) {
return {
status: 'empty',
reason: 'empty',
evidenceSpanIds: [],
lastActivityAt: fallbackTimestamp,
errorCount: 0,
pendingModelCalls: 0,
pendingToolCalls: 0,
modelCalls: 0,
toolCalls: 0,
}
}
if (modelErrors.length > 0) {
return createDiagnosis('blocked', 'model_error', modelErrors, spans, fallbackTimestamp)
}
if (toolErrors.length > 0) {
return createDiagnosis('blocked', 'tool_error', toolErrors, spans, fallbackTimestamp)
}
if (eventErrors.length > 0) {
return createDiagnosis('blocked', 'event_error', eventErrors, spans, fallbackTimestamp)
}
if (pendingModels.length > 0) {
return createDiagnosis('attention', 'pending_model', pendingModels, spans, fallbackTimestamp)
}
if (pendingTools.length > 0) {
return createDiagnosis('attention', 'pending_tool', pendingTools, spans, fallbackTimestamp)
}
if (lastTurnHasUser && !lastTurnHasAgentWork) {
const evidence = lastTurnSpans.length > 0 ? lastTurnSpans : lastSpan ? [lastSpan] : []
return createDiagnosis('attention', 'waiting_for_agent', evidence, spans, fallbackTimestamp)
}
return createDiagnosis('healthy', 'healthy', lastSpan ? [lastSpan] : [], spans, fallbackTimestamp)
}
function createDiagnosis(
status: TraceDiagnosis['status'],
reason: TraceDiagnosisReason,
evidence: TraceSpan[],
spans: TraceSpan[],
fallbackTimestamp: string,
): TraceDiagnosis {
const errors = spans.filter((span) => span.status === 'error')
const pendingModels = spans.filter((span) => span.kind === 'llm' && span.status === 'pending')
const pendingTools = spans.filter((span) => span.kind === 'tool' && span.status === 'pending')
const lastActivityAt = spans
.slice()
.sort((a, b) => compareSpanActivityTime(a, b))
.at(-1)
const lastActivityTimestamp = lastActivityAt ? spanActivityTimestamp(lastActivityAt) : undefined
return {
status,
reason,
focusSpanId: evidence[0]?.id,
evidenceSpanIds: evidence.map((span) => span.id),
lastActivityAt: lastActivityTimestamp ?? fallbackTimestamp,
errorCount: errors.length,
pendingModelCalls: pendingModels.length,
pendingToolCalls: pendingTools.length,
modelCalls: spans.filter((span) => span.kind === 'llm').length,
toolCalls: spans.filter((span) => span.kind === 'tool').length,
}
}
export function extractTextContent(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return formatUnknown(content)
return content
.flatMap((block) => {
if (!block || typeof block !== 'object') return []
const record = block as TextBlock
if (typeof record.text === 'string') return [record.text]
if ('content' in record && typeof (record as { content?: unknown }).content === 'string') {
return [(record as { content: string }).content]
}
return []
})
.join('\n')
}
export function previewTraceValue(value: unknown, maxChars = 180): string {
const text = extractTextContent(value).replace(/\s+/g, ' ').trim()
if (!text) return 'empty'
return text.length > maxChars ? `${text.slice(0, maxChars)}...` : text
}
export function formatTraceJson(value: unknown): string {
if (typeof value === 'string') {
const parsed = parseJson(value)
return parsed === null ? value : JSON.stringify(parsed, null, 2)
}
return formatUnknown(value)
}
export function getTraceValueLanguage(value: unknown, fallback: 'json' | 'text' = 'json'): 'json' | 'text' {
if (typeof value !== 'string') return 'json'
return parseJson(value) === null ? fallback : 'json'
}
function addMessageSpan(
spans: Map<string, MutableSpan>,
id: string,
parentId: string,
turnIndex: number,
message: MessageEntry,
) {
addSpan(spans, {
id,
parentId,
kind: 'message',
status: 'ok',
title: titleForMessage(message),
subtitle: previewTraceValue(message.content),
timestamp: message.timestamp,
turnIndex,
message,
raw: message,
})
}
function addToolResultSpan(
spans: Map<string, MutableSpan>,
id: string,
parentId: string,
turnIndex: number,
message: MessageEntry,
block: ToolResultBlock,
) {
addSpan(spans, {
id,
parentId,
kind: 'tool_result',
status: block.is_error ? 'error' : 'ok',
title: block.is_error ? 'Tool error' : 'Tool result',
subtitle: previewTraceValue(block.content),
timestamp: message.timestamp,
turnIndex,
message,
toolUseId: block.tool_use_id,
output: block.content,
raw: block,
})
const parent = spans.get(parentId)
if (parent?.kind === 'tool') {
parent.status = block.is_error ? 'error' : 'ok'
const existingOutput = Array.isArray(parent.output) ? parent.output : parent.output === undefined ? [] : [parent.output]
parent.output = [...existingOutput, block.content]
applyToolCompletion(parent, message.timestamp)
}
}
function latestToolResultTimestamp(startedAt: string, results: ToolResultOccurrence[]): string | undefined {
let latest: { value: string; time: number } | undefined
const startTime = new Date(startedAt).getTime()
if (!Number.isFinite(startTime)) return undefined
for (const result of results) {
const time = new Date(result.message.timestamp).getTime()
if (!Number.isFinite(time) || time < startTime) continue
if (!latest || time > latest.time) {
latest = { value: result.message.timestamp, time }
}
}
return latest?.value
}
function spanTimingFromTimestamps(
startedAt: string,
endedAt?: string,
options: { completed?: boolean } = {},
): Pick<TraceSpan, 'completedAt' | 'durationMs'> {
if (!endedAt) return {}
const startTime = new Date(startedAt).getTime()
const endTime = new Date(endedAt).getTime()
if (!Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime < startTime) return {}
return {
...(options.completed ? { completedAt: endedAt } : {}),
durationMs: endTime - startTime,
}
}
function applyToolCompletion(parent: MutableSpan, completedAt: string) {
const timing = spanTimingFromTimestamps(parent.timestamp, completedAt, { completed: true })
if (!timing.completedAt || timing.durationMs === undefined) return
const currentCompletedTime = parent.completedAt ? new Date(parent.completedAt).getTime() : Number.NEGATIVE_INFINITY
const nextCompletedTime = new Date(timing.completedAt).getTime()
if (Number.isFinite(currentCompletedTime) && currentCompletedTime > nextCompletedTime) return
parent.completedAt = timing.completedAt
parent.durationMs = timing.durationMs
}
function applyAggregateTiming(parent: MutableSpan, children: MutableSpan[]) {
const startTime = new Date(parent.timestamp).getTime()
if (!Number.isFinite(startTime)) return
let endTime = startTime
let completedAt: string | undefined
for (const child of children) {
const activity = spanActivity(child)
if (!activity) continue
if (activity.time >= endTime) {
endTime = activity.time
completedAt = activity.timestamp
}
}
if (endTime < startTime) return
parent.durationMs = endTime - startTime
if (parent.status !== 'pending' && completedAt) {
parent.completedAt = completedAt
} else if (parent.status === 'pending') {
delete parent.completedAt
}
}
function addSpan(spans: Map<string, MutableSpan>, span: Omit<TraceSpan, 'childIds'>) {
if (spans.has(span.id)) return
spans.set(span.id, { ...span, childIds: [] })
if (span.parentId) {
const parent = spans.get(span.parentId)
if (parent && !parent.childIds.includes(span.id)) {
parent.childIds.push(span.id)
}
}
}
function orderSpans(spans: Map<string, MutableSpan>, rootId: string): string[] {
const result: string[] = []
const visit = (id: string) => {
const span = spans.get(id)
if (!span) return
result.push(id)
span.childIds.sort((a, b) => compareSpanTime(spans.get(a), spans.get(b)))
for (const childId of span.childIds) visit(childId)
}
visit(rootId)
return result
}
function compareSpanTime(a?: MutableSpan, b?: MutableSpan): number {
if (!a || !b) return 0
const diff = new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
if (Number.isFinite(diff) && diff !== 0) return diff
return a.id.localeCompare(b.id)
}
function compareSpanActivityTime(a?: TraceSpan, b?: TraceSpan): number {
if (!a || !b) return 0
const aActivity = spanActivity(a)
const bActivity = spanActivity(b)
if (aActivity && bActivity && aActivity.time !== bActivity.time) return aActivity.time - bActivity.time
if (aActivity && !bActivity) return 1
if (!aActivity && bActivity) return -1
return compareSpanTime(a as MutableSpan, b as MutableSpan)
}
function spanActivityTimestamp(span: TraceSpan): string | undefined {
return spanActivity(span)?.timestamp
}
function spanActivity(span: TraceSpan): { timestamp: string; time: number } | undefined {
const startTime = new Date(span.timestamp).getTime()
if (!Number.isFinite(startTime)) return undefined
if (span.completedAt) {
const completedTime = new Date(span.completedAt).getTime()
if (Number.isFinite(completedTime)) return { timestamp: span.completedAt, time: completedTime }
}
if (span.durationMs !== undefined && Number.isFinite(span.durationMs) && span.durationMs >= 0) {
const endTime = startTime + span.durationMs
return { timestamp: new Date(endTime).toISOString(), time: endTime }
}
return { timestamp: span.timestamp, time: startTime }
}
function createTurn(index: number, timestamp: string, title: string): TraceTurn {
return {
id: `turn:${index}`,
index,
title,
timestamp,
spanIds: [],
}
}
function createTurnTitle(index: number, content: unknown): string {
const preview = previewTraceValue(content, 54)
return preview === 'empty' ? `Turn ${index + 1}` : preview
}
function createTurnLookup(turns: TraceTurn[]): (timestamp: string) => TraceTurn {
// Preserve last-matching-turn semantics for out-of-order child activity
// without scanning every turn for every message, call, and event.
const entries = turns.map((turn, index) => ({ time: new Date(turn.timestamp).getTime(), index }))
.filter((entry) => Number.isFinite(entry.time))
.sort((a, b) => a.time - b.time)
let latestIndex = 0
const latestTurns = entries.map((entry) => {
latestIndex = Math.max(latestIndex, entry.index)
return turns[latestIndex]!
})
return (timestamp) => {
const time = new Date(timestamp).getTime()
if (!Number.isFinite(time)) return turns[0]!
let lo = 0
let hi = entries.length
while (lo < hi) {
const mid = (lo + hi) >>> 1
if (entries[mid]!.time <= time) lo = mid + 1
else hi = mid
}
return lo === 0 ? turns[0]! : latestTurns[lo - 1]!
}
}
function earliestTimestamp(
calls: TraceCallRecord[],
messages: MessageEntry[],
events: TraceEventRecord[] = [],
): string | null {
const timestamps = [
...calls.map((call) => call.startedAt),
...messages.map((message) => message.timestamp),
...events.map((event) => event.timestamp),
]
.map((value) => ({ value, time: new Date(value).getTime() }))
.filter((entry) => Number.isFinite(entry.time))
.sort((a, b) => a.time - b.time)
return timestamps[0]?.value ?? null
}
function normalizeNowTimestamp(value: TraceViewModelOptions['now']): string | undefined {
if (value === undefined) return new Date().toISOString()
const date = value instanceof Date ? value : new Date(value)
return Number.isNaN(date.getTime()) ? undefined : date.toISOString()
}
function hasToolResultBlocks(content: unknown): boolean {
return extractToolResultBlocks(content).length > 0
}
function extractToolUseBlocks(content: unknown): ToolUseBlock[] {
if (!Array.isArray(content)) return []
return content.filter((block): block is ToolUseBlock =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'tool_use'
)
}
function extractToolResultBlocks(content: unknown): ToolResultBlock[] {
if (!Array.isArray(content)) return []
return content.filter((block): block is ToolResultBlock =>
!!block &&
typeof block === 'object' &&
(block as { type?: unknown }).type === 'tool_result'
)
}
function titleForMessage(message: MessageEntry): string {
switch (message.type) {
case 'user': return 'User message'
case 'assistant': return message.model ? `Assistant · ${message.model}` : 'Assistant message'
case 'system': return 'System message'
case 'tool_use': return 'Assistant tool request'
case 'tool_result': return 'Tool result'
default: return message.type
}
}
function summarizeToolInput(input: unknown): string {
if (input && typeof input === 'object' && !Array.isArray(input)) {
const record = input as Record<string, unknown>
const primary = record.command ?? record.file_path ?? record.path ?? record.query ?? record.description
if (typeof primary === 'string' && primary.trim()) return primary.trim()
}
return previewTraceValue(input, 140)
}
function getCallStatus(call: TraceCallRecord): TraceSpanStatus {
if (call.status === 'error' || call.error || (call.response?.status ?? 200) >= 400) return 'error'
if (call.status === 'pending' || !call.response) return 'pending'
return 'ok'
}
function getEventStatus(event: TraceEventRecord): TraceSpanStatus {
return event.severity === 'error' ? 'error' : 'ok'
}
const LIFECYCLE_NOISE_PHASES = new Set([
'api_call_started',
'api_call_completed',
'upstream_fetch_started',
'upstream_fetch_completed',
])
function isLifecycleNoiseEvent(event: TraceEventRecord): boolean {
return event.severity === 'info' && LIFECYCLE_NOISE_PHASES.has(event.phase)
}
function formatTraceEventPhase(phase: string): string {
return phase
.split(/[_\s-]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ')
}
function formatUnknown(value: unknown): string {
try {
// JSON.stringify(undefined) yields undefined, not a string.
return JSON.stringify(value, null, 2) ?? String(value)
} catch {
return String(value)
}
}
function parseJson(value: string): unknown | null {
const trimmed = value.trim()
if (!trimmed || (!trimmed.startsWith('{') && !trimmed.startsWith('['))) return null
try {
return JSON.parse(trimmed)
} catch {
return null
}
}
+58
View File
@@ -0,0 +1,58 @@
/** Where a trajectory row lives in the chat transcript. */
export type ChatNavigationTarget = {
toolUseId?: string
/** Transcript entry uuids of the row; any one of them identifies its chat item. */
uuids?: string[]
}
type ToolCallLike = { toolUseId?: string; originalToolUseId?: string }
type MessageLike = ToolCallLike & { id: string; type: string; transcriptMessageId?: string }
/** Structural view of MessageList's render items, so this stays testable without it. */
export type ChatRenderItemLike =
| { kind: 'message'; message: MessageLike }
| { kind: 'tool_group'; id: string; toolCalls: readonly ToolCallLike[] }
| { kind: 'team_card'; id: string; coordinationToolCalls: readonly ToolCallLike[] }
function findToolUse(calls: readonly ToolCallLike[], toolUseId: string): ToolCallLike | undefined {
return calls.find((call) => call.toolUseId === toolUseId || call.originalToolUseId === toolUseId)
}
/**
* Where `target` renders: the render item's index, plus — when it was found by
* a tool call — the id that call renders under. A group folds its calls away,
* so scrolling to the group alone shows nothing; the caller needs the call's
* own id to open the group and point at that row.
*/
export type ChatRenderTarget = {
index: number
/** The matched call's rendered `toolUseId` (not necessarily the requested original id). */
toolUseId?: string
}
export function findChatRenderTarget(items: readonly ChatRenderItemLike[], target: ChatNavigationTarget): ChatRenderTarget {
const { toolUseId } = target
if (toolUseId) {
for (let index = 0; index < items.length; index += 1) {
const item = items[index]!
const call = item.kind === 'tool_group'
? findToolUse(item.toolCalls, toolUseId)
: item.kind === 'team_card'
? findToolUse(item.coordinationToolCalls, toolUseId)
: item.message.type === 'tool_use' ? findToolUse([item.message], toolUseId) : undefined
if (call) return { index, toolUseId: call.toolUseId }
}
}
const uuids = new Set(target.uuids ?? [])
if (!uuids.size) return { index: -1 }
return {
index: items.findIndex((item) => item.kind === 'message' && (
uuids.has(item.message.id) || (item.message.transcriptMessageId !== undefined && uuids.has(item.message.transcriptMessageId))
)),
}
}
/** Index of the render item that shows `target`, or -1 when it is not loaded. */
export function findChatRenderIndex(items: readonly ChatRenderItemLike[], target: ChatNavigationTarget): number {
return findChatRenderTarget(items, target).index
}
+126
View File
@@ -0,0 +1,126 @@
import type { TrajectoryRow } from '../../types/trajectory'
export type ToolUseBlock = { id: string; name: string; input: unknown }
export type TrajectoryDetailContent = {
/** Markdown-renderable text of the row (prompt, response, injected text). */
text: string
thinking: string[]
toolUses: ToolUseBlock[]
/** Tool rows: the call input. */
toolInput?: unknown
/** Tool rows: the result text as the model saw it. */
toolResult?: string
toolResultIsError?: boolean
/** Tool rows: the structured result the harness recorded (toolUseResult). */
toolResultStructured?: unknown
/** Context rows backed by an attachment record. */
attachment?: unknown
imageCount: number
}
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
function blocksOf(entry: Record<string, unknown>): unknown[] {
const message = entry.message
if (!isRecord(message)) return []
const content = message.content
if (typeof content === 'string') return [{ type: 'text', text: content }]
return Array.isArray(content) ? content : []
}
function textOfResult(content: unknown): string {
if (typeof content === 'string') return content
if (!Array.isArray(content)) return content === undefined ? '' : JSON.stringify(content, null, 2)
return content
.map((block) => {
if (!isRecord(block)) return ''
if (block.type === 'text' && typeof block.text === 'string') return block.text
if (block.type === 'image') return '[image]'
return JSON.stringify(block, null, 2)
})
.filter(Boolean)
.join('\n\n')
}
/** Pull the readable parts out of a row's raw transcript records. */
export function extractDetailContent(row: TrajectoryRow, entries: readonly Record<string, unknown>[]): TrajectoryDetailContent {
const content: TrajectoryDetailContent = { text: '', thinking: [], toolUses: [], imageCount: 0 }
const texts: string[] = []
for (const entry of entries) {
if (entry.type === 'attachment') {
content.attachment = entry.attachment
continue
}
if (entry.type === 'system') {
if (typeof entry.content === 'string') texts.push(entry.content)
continue
}
for (const block of blocksOf(entry)) {
if (!isRecord(block)) continue
switch (block.type) {
case 'text':
if (typeof block.text === 'string' && row.kind !== 'tool') texts.push(block.text)
break
case 'thinking':
if (typeof block.thinking === 'string' && block.thinking) content.thinking.push(block.thinking)
break
case 'redacted_thinking':
content.thinking.push('[redacted thinking]')
break
case 'image':
content.imageCount++
break
case 'tool_use':
if (typeof block.id === 'string' && typeof block.name === 'string') {
const use = { id: block.id, name: block.name, input: block.input }
if (row.kind === 'tool') {
if (block.id === row.toolUseId) content.toolInput = block.input
} else {
content.toolUses.push(use)
}
}
break
case 'tool_result':
if (row.kind === 'tool' && block.tool_use_id === row.toolUseId) {
content.toolResult = textOfResult(block.content)
if (Array.isArray(block.content)) {
content.imageCount += block.content.filter((item) => isRecord(item) && item.type === 'image').length
}
content.toolResultIsError = block.is_error === true
if (entry.toolUseResult !== undefined) content.toolResultStructured = entry.toolUseResult
}
break
}
}
}
content.text = texts.join('\n\n')
return content
}
/** One readable value per attachment, for the preview tab. */
export function attachmentText(attachment: unknown): string {
if (!isRecord(attachment)) return ''
const strings: string[] = []
const visit = (value: unknown, depth: number) => {
if (depth > 4 || strings.length > 200) return
if (typeof value === 'string') {
if (value.trim()) strings.push(value)
return
}
if (Array.isArray(value)) {
for (const item of value) visit(item, depth + 1)
return
}
if (isRecord(value)) {
for (const [key, child] of Object.entries(value)) {
if (key === 'type') continue
visit(child, depth + 1)
}
}
}
visit(attachment, 0)
return strings.join('\n\n')
}
@@ -0,0 +1,67 @@
import { describe, expect, it, vi } from 'vitest'
import type { TraceCallRecord } from '../../types/trace'
import type { TraceCallsNear } from '../../types/trajectory'
import { findTraceCallForRow, scoreTraceCall } from './findTraceCall'
import { makeRow } from './trajectoryFixtures'
function at(seconds: number) {
return new Date(Date.UTC(2026, 9, 4, 10, 0, 0) + seconds * 1000).toISOString()
}
function call(id: string, start: number, end: number, model = 'm'): TraceCallRecord {
return {
id,
sessionId: 's',
source: 'anthropic',
model,
status: 'ok',
startedAt: at(start),
completedAt: at(end),
durationMs: (end - start) * 1000,
request: { method: 'POST', url: '', headers: {}, body: { contentType: 'json', bytes: 0, sha256: '', preview: '', truncated: false } },
} as TraceCallRecord
}
describe('scoreTraceCall', () => {
const row = makeRow({ id: 'a:1', kind: 'assistant', startTs: at(100), ts: at(104), endTs: at(110), model: 'm' })
it('rejects calls that started after the response began (beyond clock-skew slack)', () => {
expect(scoreTraceCall(call('late', 107, 110), row)).toBeNull()
})
it('prefers the call that completed closest to the last response block', () => {
const close = scoreTraceCall(call('close', 100, 110), row)!
const far = scoreTraceCall(call('far', 100, 104), row)!
expect(close).toBeLessThan(far)
})
it('penalizes a different model, so a parallel side query does not win', () => {
const same = scoreTraceCall(call('same', 100, 111, 'm'), row)!
const other = scoreTraceCall(call('other', 100, 110, 'haiku'), row)!
expect(same).toBeLessThan(other)
})
})
describe('findTraceCallForRow', () => {
const row = makeRow({ id: 'a:x', kind: 'assistant', startTs: at(7350), ts: at(7355), endTs: at(7358), model: 'm' })
it('asks the server for calls around the request window and picks the best', async () => {
const load = vi.fn(async (): Promise<TraceCallsNear> => ({
captured: true,
revisionToken: 'rev',
calls: [call('before', 7340, 7349), call('match', 7350, 7358), call('parallel', 7351, 7400)],
}))
const result = await findTraceCallForRow(row, load)
expect(result).toMatchObject({ status: 'matched', call: { id: 'match' }, revisionKey: 'rev' })
expect(load).toHaveBeenCalledTimes(1)
const [from, to] = load.mock.calls[0] as unknown as [string, string]
// The window starts before the request (skew slack) and ends just after the first block.
expect(Date.parse(from)).toBe(Date.parse(at(7348)))
expect(Date.parse(to)).toBe(Date.parse(at(7357)))
})
it('tells "never captured" apart from "captured but no match"', async () => {
expect(await findTraceCallForRow(row, async () => ({ captured: false, calls: [] }))).toEqual({ status: 'uncaptured' })
expect(await findTraceCallForRow(row, async () => ({ captured: true, calls: [call('late', 9000, 9001)] }))).toEqual({ status: 'unmatched' })
})
})
@@ -0,0 +1,71 @@
import type { TraceCallRecord } from '../../types/trace'
import type { TraceCallsNear, TrajectoryRow } from '../../types/trajectory'
export type TraceCallsNearLoader = (from: string, to: string) => Promise<TraceCallsNear>
export type TraceCallLookup =
| { status: 'matched'; call: TraceCallRecord; revisionKey?: string; at?: string }
/** Captures exist for the session, but none explains this response. */
| { status: 'unmatched' }
/** The session has no capture at all (capture was off while it ran). */
| { status: 'uncaptured' }
function timeOf(value: string | undefined | null): number | null {
if (!value) return null
const ms = Date.parse(value)
return Number.isFinite(ms) ? ms : null
}
/** Slack for clock skew between the capture writer and the transcript writer. */
const START_SLACK_MS = 2_000
const END_SLACK_MS = 30_000
/** How far before the first response block a request may have started when its start is unknown. */
const UNKNOWN_START_LOOKBACK_MS = 10 * 60_000
/**
* Score how well a captured call explains an assistant response: the call
* must start no later than the response's first block, and the closer its
* completion is to the response's last block the better. Lower is better;
* null means "cannot be this response".
*/
export function scoreTraceCall(call: TraceCallRecord, row: TrajectoryRow): number | null {
const responseStart = timeOf(row.ts)
const responseEnd = timeOf(row.endTs ?? row.ts)
const requestStart = timeOf(row.startTs) ?? (responseStart === null ? null : responseStart - UNKNOWN_START_LOOKBACK_MS)
const startedAt = timeOf(call.startedAt)
if (responseStart === null || responseEnd === null || requestStart === null || startedAt === null) return null
if (startedAt < requestStart - START_SLACK_MS || startedAt > responseStart + START_SLACK_MS) return null
const completedAt = timeOf(call.completedAt) ?? startedAt + (call.durationMs ?? 0)
if (completedAt > responseEnd + END_SLACK_MS) return null
let score = Math.abs(completedAt - responseEnd)
if (row.model && call.model && row.model !== call.model) score += 60_000
if (call.status === 'error') score += 30_000
return score
}
/**
* Find the captured call behind an assistant row. The capture is located by
* time on the server (through its index), so this costs one request however
* large the capture file is.
*/
export async function findTraceCallForRow(row: TrajectoryRow, loadNear: TraceCallsNearLoader): Promise<TraceCallLookup> {
const responseStart = timeOf(row.ts)
if (responseStart === null) return { status: 'unmatched' }
const requestStart = timeOf(row.startTs) ?? responseStart - UNKNOWN_START_LOOKBACK_MS
const from = new Date(requestStart - START_SLACK_MS).toISOString()
const to = new Date(responseStart + START_SLACK_MS).toISOString()
const near = await loadNear(from, to)
if (!near.captured) return { status: 'uncaptured' }
let best: TraceCallRecord | null = null
let bestScore = Infinity
for (const call of near.calls) {
const score = scoreTraceCall(call, row)
if (score !== null && score < bestScore) {
best = call
bestScore = score
}
}
return best
? { status: 'matched', call: best, revisionKey: near.revisionToken, at: near.locs?.[best.id] }
: { status: 'unmatched' }
}
+114
View File
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest'
import { findChatRenderIndex, findChatRenderTarget } from './chatTarget'
import { attachmentText, extractDetailContent } from './detailContent'
import { diffLines, diffToolCatalogs } from './lineDiff'
import { computeFixedRowWindow, visibleRange } from './useFixedRowWindow'
import { makeRow } from './trajectoryFixtures'
describe('diffLines', () => {
it('keeps the shared prefix and suffix and marks only the changed middle', () => {
const diff = diffLines('a\nb\nc\nd', 'a\nB\nc\nd\ne')
expect(diff).toEqual([
{ type: 'same', text: 'a' },
{ type: 'remove', text: 'b' },
{ type: 'add', text: 'B' },
{ type: 'same', text: 'c' },
{ type: 'same', text: 'd' },
{ type: 'add', text: 'e' },
])
})
it('reports nothing changed for identical text', () => {
expect(diffLines('x\ny', 'x\ny').every((line) => line.type === 'same')).toBe(true)
})
})
describe('diffToolCatalogs', () => {
it('separates added, removed and changed tools by name', () => {
const diff = diffToolCatalogs(
[{ name: 'Bash', description: 'run' }, { name: 'Read', description: 'read' }],
[{ name: 'Bash', description: 'run commands' }, { name: 'WebFetch', description: 'fetch' }],
)
expect(diff).toEqual({ added: ['WebFetch'], removed: ['Read'], changed: ['Bash'] })
})
})
describe('visibleRange', () => {
it('reports the rows actually on screen, without overscan', () => {
expect(visibleRange(3000, 600, 30, 10_000)).toEqual({ first: 100, last: 119 })
expect(visibleRange(0, 600, 30, 5)).toEqual({ first: 0, last: 4 })
})
})
describe('computeFixedRowWindow', () => {
it('renders the visible rows plus overscan on both sides', () => {
expect(computeFixedRowWindow(3000, 600, 30, 10_000, 12)).toEqual({ start: 88, end: 133 })
})
it('clamps to the list bounds', () => {
expect(computeFixedRowWindow(0, 600, 30, 5, 12)).toEqual({ start: 0, end: 5 })
expect(computeFixedRowWindow(10_000, 600, 30, 0, 12)).toEqual({ start: 0, end: 0 })
})
})
describe('findChatRenderIndex', () => {
const items = [
{ kind: 'message' as const, message: { id: 'u-uuid', type: 'user_text' } },
{ kind: 'tool_group' as const, id: 'g1', toolCalls: [{ toolUseId: 'toolu_1' }, { toolUseId: 'ns/toolu_x', originalToolUseId: 'toolu_2' }] },
{ kind: 'message' as const, message: { id: 'live-id', type: 'assistant_text', transcriptMessageId: 'a-uuid' } },
]
it('finds a tool call inside an activity group, including by its original id', () => {
expect(findChatRenderIndex(items, { toolUseId: 'toolu_1' })).toBe(1)
expect(findChatRenderIndex(items, { toolUseId: 'toolu_2' })).toBe(1)
})
it('finds a message by entry uuid or by the transcript id a live message carries', () => {
expect(findChatRenderIndex(items, { uuids: ['u-uuid'] })).toBe(0)
expect(findChatRenderIndex(items, { uuids: ['nope', 'a-uuid'] })).toBe(2)
})
it('returns -1 when the target is not loaded', () => {
expect(findChatRenderIndex(items, { toolUseId: 'missing', uuids: ['missing'] })).toBe(-1)
})
it('names the call a group renders, so the caller can open the group onto that row', () => {
expect(findChatRenderTarget(items, { toolUseId: 'toolu_1' })).toEqual({ index: 1, toolUseId: 'toolu_1' })
// Matched by its original id, the row is still found under the id it renders with.
expect(findChatRenderTarget(items, { toolUseId: 'toolu_2' })).toEqual({ index: 1, toolUseId: 'ns/toolu_x' })
expect(findChatRenderTarget(items, { toolUseId: 'missing', uuids: ['u-uuid'] })).toEqual({ index: 0 })
})
})
describe('extractDetailContent', () => {
it('pulls the call input and the result the model saw for a tool row', () => {
const row = makeRow({ id: 't:toolu_1', kind: 'tool', toolUseId: 'toolu_1' })
const content = extractDetailContent(row, [
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'ls' } }] } },
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: [{ type: 'text', text: 'a.txt' }], is_error: false }] }, toolUseResult: { stdout: 'a.txt' } },
])
expect(content.toolInput).toEqual({ command: 'ls' })
expect(content.toolResult).toBe('a.txt')
expect(content.toolResultStructured).toEqual({ stdout: 'a.txt' })
})
it('collects text, thinking and issued tool calls for an assistant row', () => {
const row = makeRow({ id: 'a:m1', kind: 'assistant' })
const content = extractDetailContent(row, [
{ type: 'assistant', message: { content: [{ type: 'thinking', thinking: 'plan' }] } },
{ type: 'assistant', message: { content: [{ type: 'text', text: 'Running it' }] } },
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'toolu_9', name: 'Read', input: {} }] } },
])
expect(content.thinking).toEqual(['plan'])
expect(content.text).toBe('Running it')
expect(content.toolUses.map((use) => use.name)).toEqual(['Read'])
})
it('keeps the attachment of a context row and renders its strings for preview', () => {
const row = makeRow({ id: 'c:1', kind: 'context' })
const attachment = { type: 'skill_listing', content: '- pdf: read PDFs', skillCount: 1 }
const content = extractDetailContent(row, [{ type: 'attachment', attachment }])
expect(content.attachment).toEqual(attachment)
expect(attachmentText(content.attachment)).toBe('- pdf: read PDFs')
})
})
+85
View File
@@ -0,0 +1,85 @@
export type DiffLine = { type: 'same' | 'add' | 'remove'; text: string }
const MAX_LCS_CELLS = 4_000_000
/**
* Line diff for system prompt revisions. Common prefix/suffix are trimmed
* first (prompt updates usually touch one region), then an LCS runs on the
* changed middle. A middle too large for the cell budget degrades to "all
* removed, all added", which is still correct, just less precise.
*/
export function diffLines(before: string, after: string): DiffLine[] {
const a = before.split('\n')
const b = after.split('\n')
let prefix = 0
while (prefix < a.length && prefix < b.length && a[prefix] === b[prefix]) prefix++
let suffix = 0
while (
suffix < a.length - prefix &&
suffix < b.length - prefix &&
a[a.length - 1 - suffix] === b[b.length - 1 - suffix]
) suffix++
const head: DiffLine[] = a.slice(0, prefix).map((text) => ({ type: 'same', text }))
const tail: DiffLine[] = a.slice(a.length - suffix).map((text) => ({ type: 'same', text }))
const midA = a.slice(prefix, a.length - suffix)
const midB = b.slice(prefix, b.length - suffix)
if (midA.length * midB.length > MAX_LCS_CELLS) {
return [
...head,
...midA.map((text): DiffLine => ({ type: 'remove', text })),
...midB.map((text): DiffLine => ({ type: 'add', text })),
...tail,
]
}
const rows = midA.length
const cols = midB.length
const table = new Uint32Array((rows + 1) * (cols + 1))
for (let i = rows - 1; i >= 0; i--) {
for (let j = cols - 1; j >= 0; j--) {
table[i * (cols + 1) + j] = midA[i] === midB[j]
? table[(i + 1) * (cols + 1) + j + 1]! + 1
: Math.max(table[(i + 1) * (cols + 1) + j]!, table[i * (cols + 1) + j + 1]!)
}
}
const middle: DiffLine[] = []
let i = 0
let j = 0
while (i < rows && j < cols) {
if (midA[i] === midB[j]) {
middle.push({ type: 'same', text: midA[i]! })
i++
j++
} else if (table[(i + 1) * (cols + 1) + j]! >= table[i * (cols + 1) + j + 1]!) {
middle.push({ type: 'remove', text: midA[i]! })
i++
} else {
middle.push({ type: 'add', text: midB[j]! })
j++
}
}
while (i < rows) middle.push({ type: 'remove', text: midA[i++]! })
while (j < cols) middle.push({ type: 'add', text: midB[j++]! })
return [...head, ...middle, ...tail]
}
export type ToolCatalogEntry = { name: string; description?: string; input_schema?: unknown }
export type ToolCatalogDiff = { added: string[]; removed: string[]; changed: string[] }
export function diffToolCatalogs(before: readonly ToolCatalogEntry[], after: readonly ToolCatalogEntry[]): ToolCatalogDiff {
const previous = new Map(before.map((tool) => [tool.name, JSON.stringify(tool)]))
const next = new Map(after.map((tool) => [tool.name, JSON.stringify(tool)]))
const added: string[] = []
const removed: string[] = []
const changed: string[] = []
for (const [name, value] of next) {
const old = previous.get(name)
if (old === undefined) added.push(name)
else if (old !== value) changed.push(name)
}
for (const name of previous.keys()) if (!next.has(name)) removed.push(name)
return { added, removed, changed }
}
+100
View File
@@ -0,0 +1,100 @@
import type { TrajectoryLoc, TrajectoryRow, TrajectoryUsage } from '../../types/trajectory'
function mergeLoc(a: TrajectoryLoc[], b: TrajectoryLoc[]): TrajectoryLoc[] {
if (!b.length) return a
if (!a.length) return b
const byStart = new Map<number, TrajectoryLoc>()
for (const loc of a) byStart.set(loc[0], loc)
let added = false
for (const loc of b) {
if (!byStart.has(loc[0])) {
byStart.set(loc[0], loc)
added = true
}
}
if (!added) return a
return [...byStart.values()].sort((x, y) => x[0] - y[0])
}
function earlier(a: string | undefined, b: string | undefined): string | undefined {
if (!a) return b
if (!b) return a
return a <= b ? a : b
}
function later(a: string | undefined, b: string | undefined): string | undefined {
if (!a) return b
if (!b) return a
return a >= b ? a : b
}
/**
* Desktop transcripts write the final usage only on the last block line of a
* response (earlier lines carry 0), so whichever piece arrived first may hold
* the zeros. Usage only ever grows within one response: take the max.
*/
function maxUsage(a: TrajectoryUsage | undefined, b: TrajectoryUsage | undefined): TrajectoryUsage | undefined {
if (!a) return b
if (!b) return a
return {
input: Math.max(a.input, b.input),
output: Math.max(a.output, b.output),
cacheRead: Math.max(a.cacheRead, b.cacheRead),
cacheWrite: Math.max(a.cacheWrite, b.cacheWrite),
}
}
function minTurn(a: number | null, b: number | null): number | null {
if (a === null) return b
if (b === null) return a
return Math.min(a, b)
}
/**
* Merge two pieces of the same row. The server emits a piece per page/append
* (an assistant message split by a page boundary, a tool_result that lands
* after its tool_use was already delivered); merging must be idempotent so a
* re-delivered piece changes nothing.
*/
export function mergeRowPieces(a: TrajectoryRow, b: TrajectoryRow): TrajectoryRow {
const first = a.ts <= b.ts ? a : b
const second = first === a ? b : a
const loc = mergeLoc(a.loc, b.loc)
return {
...second,
...first,
turn: minTurn(a.turn, b.turn),
ts: first.ts,
endTs: later(later(a.endTs, b.endTs), second.ts > first.ts ? second.ts : undefined),
loc,
preview: first.preview || second.preview,
partial: Boolean(a.partial && b.partial) || undefined,
isError: a.isError || b.isError || undefined,
toolName: a.toolName ?? b.toolName,
toolUseId: a.toolUseId ?? b.toolUseId,
parentId: a.parentId ?? b.parentId,
inputPreview: a.inputPreview ?? b.inputPreview,
inputSummary: a.inputSummary ?? b.inputSummary,
resultPreview: a.resultPreview ?? b.resultPreview,
resultTs: a.resultTs ?? b.resultTs,
resultOmittedBytes: a.resultOmittedBytes ?? b.resultOmittedBytes,
agentId: a.agentId ?? b.agentId,
model: a.model ?? b.model,
requestId: a.requestId ?? b.requestId,
usage: maxUsage(a.usage, b.usage),
startTs: earlier(a.startTs, b.startTs),
toolOnly: a.toolOnly === undefined ? b.toolOnly : b.toolOnly === undefined ? a.toolOnly : a.toolOnly && b.toolOnly,
hasThinking: a.hasThinking || b.hasThinking || undefined,
stopReason: second.stopReason ?? first.stopReason,
}
}
/** Returns a new map with `incoming` merged by id; `current` is not mutated. */
export function mergeRows(current: ReadonlyMap<string, TrajectoryRow>, incoming: readonly TrajectoryRow[]): Map<string, TrajectoryRow> {
const next = new Map(current)
for (const row of incoming) {
const existing = next.get(row.id)
next.set(row.id, existing ? mergeRowPieces(existing, row) : row)
}
return next
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import { buildMinimap, layoutRows, rowAtFraction } from './minimap'
import { makeRow } from './trajectoryFixtures'
function at(seconds: number) {
return new Date(Date.UTC(2026, 9, 4, 10, 0, seconds)).toISOString()
}
describe('layoutRows', () => {
it('gives every row the same slot in sequence mode', () => {
const rows = [makeRow({ id: 'a', kind: 'user' }), makeRow({ id: 'b', kind: 'assistant' })]
expect(layoutRows(rows, 'sequence')).toEqual([{ x: 0, w: 0.5 }, { x: 0.5, w: 0.5 }])
})
it('sizes rows by duration so the slow call dominates', () => {
const rows = [
makeRow({ id: 'u', kind: 'user', ts: at(0) }),
makeRow({ id: 'fast', kind: 'tool', ts: at(1), resultTs: at(2) }),
makeRow({ id: 'slow', kind: 'tool', ts: at(2), resultTs: at(11) }),
]
const [user, fast, slow] = layoutRows(rows, 'duration')
expect(slow!.w).toBeGreaterThan(fast!.w * 8)
expect(user!.w).toBeGreaterThan(0)
expect(slow!.x + slow!.w).toBeCloseTo(1)
})
})
describe('buildMinimap', () => {
it('bounds the span count by the bucket budget regardless of session size', () => {
const rows = Array.from({ length: 20_000 }, (_, index) =>
makeRow({ id: `r${index}`, kind: index % 3 === 0 ? 'user' : index % 3 === 1 ? 'assistant' : 'tool', turn: Math.floor(index / 30) }),
)
const model = buildMinimap(rows, 'sequence', { buckets: 600 })
expect(model.runs.length).toBeLessThanOrEqual(600 * 3)
expect(model.positions.size).toBe(20_000)
})
it('places runs in the lane of their row kind and flags errors and the selection', () => {
const rows = [
makeRow({ id: 'u', kind: 'user', turn: 1 }),
makeRow({ id: 'a', kind: 'assistant', turn: 1 }),
makeRow({ id: 't', kind: 'tool', turn: 1, isError: true }),
makeRow({ id: 'u2', kind: 'user', turn: 2 }),
]
const model = buildMinimap(rows, 'sequence', { selectedId: 'a' })
const byRow = new Map(model.runs.map((run) => [run.rowId, run]))
expect(byRow.get('u')!.lane).toBe(0)
expect(byRow.get('a')!.lane).toBe(1)
expect(byRow.get('a')!.selected).toBe(true)
expect(byRow.get('t')!.lane).toBe(2)
expect(byRow.get('t')!.error).toBe(true)
expect(byRow.get('t')!.kind).toBe('tool')
expect(model.turnMarks).toEqual([0.75])
// Failures also get a full-height mark so they survive bucketing.
expect(model.errorMarks.map((mark) => mark.rowId)).toEqual(['t'])
})
it('shows the prompt, not the context sharing its bucket, in the input lane', () => {
const rows = [makeRow({ id: 'c', kind: 'context' }), makeRow({ id: 'u', kind: 'user' })]
const model = buildMinimap(rows, 'sequence', { buckets: 1 })
const input = model.runs.filter((run) => run.lane === 0)
expect(input).toHaveLength(1)
expect(input[0]).toMatchObject({ kind: 'user', rowId: 'u' })
})
it('resolves a click between runs to the nearest row in that lane', () => {
const rows = [
makeRow({ id: 'u', kind: 'user' }),
makeRow({ id: 'a', kind: 'assistant' }),
makeRow({ id: 't', kind: 'tool' }),
makeRow({ id: 'a2', kind: 'assistant' }),
]
const model = buildMinimap(rows, 'sequence')
expect(rowAtFraction(model, 0.9, 1)).toBe('a2')
expect(rowAtFraction(model, 0.1, 1)).toBe('a')
expect(rowAtFraction(model, 0.6, null)).toBe('t')
})
})
+167
View File
@@ -0,0 +1,167 @@
import type { TrajectoryRow, TrajectoryRowKind } from '../../types/trajectory'
import { laneForKind, rowDurationMs, type TrajectoryLane } from './viewModel'
export type MinimapMode = 'sequence' | 'duration'
/** A run of adjacent buckets in one lane, rendered as one span. */
export type MinimapRun = {
lane: TrajectoryLane
/** Kind of the run's row; the span takes the same color as that kind's badge. */
kind: TrajectoryRowKind
/** Fractions of the plot width, 0..1. */
x: number
w: number
/** Row selected when the run is clicked. */
rowId: string
error: boolean
match: boolean
selected: boolean
}
export type MinimapModel = {
runs: MinimapRun[]
/** Turn boundaries as fractions of the plot width. */
turnMarks: number[]
/** Failed rows as full-height marks, one per bucket, so they stand out at any scale. */
errorMarks: Array<{ x: number; rowId: string }>
/** Start/end fraction for every row, for the viewport indicator and hit testing. */
positions: Map<string, { x: number; w: number; lane: TrajectoryLane }>
}
/**
* Lay rows out on a 0..1 axis. `sequence` gives every row one slot;
* `duration` sizes rows by wall-clock time with idle gaps removed, so a long
* tool call or slow response stands out. Rows with no duration still get a
* sliver so they stay clickable.
*/
export function layoutRows(rows: readonly TrajectoryRow[], mode: MinimapMode): Array<{ x: number; w: number }> {
if (!rows.length) return []
let widths: number[]
if (mode === 'sequence') {
widths = rows.map(() => 1)
} else {
const durations = rows.map((row) => rowDurationMs(row) ?? 0)
const base = durations.reduce((sum, value) => sum + value, 0)
const minWidth = base > 0 ? base * 0.002 : 1
widths = durations.map((value) => Math.max(value, minWidth))
}
const total = widths.reduce((sum, value) => sum + value, 0)
const layout: Array<{ x: number; w: number }> = []
let cursor = 0
for (const width of widths) {
layout.push({ x: cursor / total, w: width / total })
cursor += width
}
return layout
}
/**
* Bucket the layout so the minimap renders at most `buckets` spans per lane
* regardless of session size, then merge adjacent buckets of one lane into
* runs. A 20k-row session costs the same DOM as a 600-row one.
*/
export function buildMinimap(
rows: readonly TrajectoryRow[],
mode: MinimapMode,
options: { buckets?: number; selectedId?: string | null; isMatch?: (row: TrajectoryRow) => boolean } = {},
): MinimapModel {
const layout = layoutRows(rows, mode)
const positions = new Map<string, { x: number; w: number; lane: TrajectoryLane }>()
const turnMarks: number[] = []
const bucketCount = Math.max(1, Math.min(options.buckets ?? 600, rows.length))
type Cell = { rowId: string; kind: TrajectoryRowKind; error: boolean; match: boolean; selected: boolean }
const lanes: Array<Array<Cell | undefined>> = [[], [], []]
const errorBuckets = new Map<number, string>()
let previousTurn: number | null | undefined
rows.forEach((row, index) => {
const slot = layout[index]!
const lane = laneForKind(row.kind)
positions.set(row.id, { x: slot.x, w: slot.w, lane })
if (index > 0 && row.turn !== previousTurn && row.turn !== null) turnMarks.push(slot.x)
previousTurn = row.turn
const first = Math.min(bucketCount - 1, Math.floor(slot.x * bucketCount))
const last = Math.min(bucketCount - 1, Math.max(first, Math.ceil((slot.x + slot.w) * bucketCount) - 1))
const error = Boolean(row.isError)
const match = options.isMatch?.(row) ?? false
const selected = row.id === options.selectedId
if (error && !errorBuckets.has(first)) errorBuckets.set(first, row.id)
for (let bucket = first; bucket <= last; bucket++) {
const cell = lanes[lane]![bucket]
if (!cell) {
lanes[lane]![bucket] = { rowId: row.id, kind: row.kind, error, match, selected }
} else {
cell.error ||= error
cell.match ||= match
if (selected) {
cell.selected = true
cell.rowId = row.id
cell.kind = row.kind
} else if (!cell.selected && KIND_PRIORITY[row.kind] > KIND_PRIORITY[cell.kind]) {
// A prompt sharing a bucket with injected context is what the input lane should show.
cell.rowId = row.id
cell.kind = row.kind
}
}
}
})
const runs: MinimapRun[] = []
lanes.forEach((cells, lane) => {
let start = -1
let current: Cell | undefined
const flush = (end: number) => {
if (start < 0 || !current) return
runs.push({
lane: lane as TrajectoryLane,
kind: current.kind,
x: start / bucketCount,
w: (end - start) / bucketCount,
rowId: current.rowId,
error: current.error,
match: current.match,
selected: current.selected,
})
start = -1
current = undefined
}
for (let bucket = 0; bucket < bucketCount; bucket++) {
const cell = cells[bucket]
// A run only continues across buckets of the same row, so a click on a
// run always lands on a row inside it and state flags stay per-row.
if (cell && current && cell.rowId === current.rowId && cell.kind === current.kind && cell.error === current.error && cell.match === current.match && cell.selected === current.selected) continue
flush(bucket)
if (cell) {
start = bucket
current = cell
}
}
flush(bucketCount)
})
const errorMarks = [...errorBuckets].sort((a, b) => a[0] - b[0]).map(([bucket, rowId]) => ({ x: (bucket + 0.5) / bucketCount, rowId }))
return { runs, turnMarks, errorMarks, positions }
}
const KIND_PRIORITY: Record<TrajectoryRowKind, number> = {
user: 3,
system: 2,
context: 1,
assistant: 1,
compact: 2,
tool: 1,
}
/** The row nearest to fraction `x` in `lane`, for clicks between runs. */
export function rowAtFraction(model: MinimapModel, x: number, lane: TrajectoryLane | null): string | null {
let best: string | null = null
let bestDistance = Infinity
for (const [rowId, slot] of model.positions) {
if (lane !== null && slot.lane !== lane) continue
const distance = x < slot.x ? slot.x - x : x > slot.x + slot.w ? x - slot.x - slot.w : 0
if (distance < bestDistance) {
best = rowId
bestDistance = distance
if (distance === 0) break
}
}
return best
}
@@ -0,0 +1,19 @@
import type { TrajectoryRow } from '../../types/trajectory'
let offset = 0
/** Test helper: a row with a unique, increasing byte offset unless one is given. */
export function makeRow(partial: Partial<TrajectoryRow> & Pick<TrajectoryRow, 'id' | 'kind'>): TrajectoryRow {
offset += 100
return {
turn: 1,
ts: '2026-10-04T10:00:00.000Z',
preview: '',
loc: [[offset, offset + 50]],
...partial,
}
}
export function resetRowOffsets() {
offset = 0
}
@@ -0,0 +1,91 @@
import { useCallback, useEffect, useState, type RefObject } from 'react'
export type FixedRowWindow = { start: number; end: number; scrollTop: number }
export type ScrollAlign = 'nearest' | 'center' | 'end' | 'offset'
/** Pure range math, exported for tests. */
export function computeFixedRowWindow(
scrollTop: number,
viewportHeight: number,
rowHeight: number,
count: number,
overscan: number,
): Omit<FixedRowWindow, 'scrollTop'> {
if (count <= 0 || rowHeight <= 0) return { start: 0, end: 0 }
const first = Math.floor(Math.max(0, scrollTop) / rowHeight)
const visible = Math.ceil(Math.max(0, viewportHeight) / rowHeight) + 1
const start = Math.max(0, Math.min(count, first - overscan))
const end = Math.max(start, Math.min(count, first + visible + overscan))
return { start, end }
}
/** First and last fully or partly visible index, without overscan. */
export function visibleRange(scrollTop: number, viewportHeight: number, rowHeight: number, count: number): { first: number; last: number } {
if (count <= 0) return { first: 0, last: -1 }
const first = Math.min(count - 1, Math.floor(Math.max(0, scrollTop) / rowHeight))
const last = Math.min(count - 1, Math.max(first, Math.ceil((Math.max(0, scrollTop) + Math.max(0, viewportHeight)) / rowHeight) - 1))
return { first, last }
}
/**
* Windowing for a list whose rows all share one height. No measurement is
* needed, so this stays a few lines instead of a virtualization library
* (components/AGENTS.md rules those out).
*/
export function useFixedRowWindow(
containerRef: RefObject<HTMLElement | null>,
options: { count: number; rowHeight: number; overscan?: number; enabled?: boolean },
): FixedRowWindow & { viewportHeight: number; scrollToIndex: (index: number, align?: ScrollAlign, offset?: number) => void } {
const { count, rowHeight, overscan = 12, enabled = true } = options
const [viewport, setViewport] = useState({ scrollTop: 0, height: 0 })
useEffect(() => {
const element = containerRef.current
if (!element || !enabled) return
let frame = 0
const read = () => {
frame = 0
setViewport((previous) => (
previous.scrollTop === element.scrollTop && previous.height === element.clientHeight
? previous
: { scrollTop: element.scrollTop, height: element.clientHeight }
))
}
const schedule = () => {
if (!frame) frame = requestAnimationFrame(read)
}
read()
element.addEventListener('scroll', schedule, { passive: true })
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(schedule)
observer?.observe(element)
return () => {
element.removeEventListener('scroll', schedule)
observer?.disconnect()
if (frame) cancelAnimationFrame(frame)
}
}, [containerRef, enabled])
const scrollToIndex = useCallback((index: number, align: ScrollAlign = 'nearest', offset = 0) => {
const element = containerRef.current
if (!element || index < 0) return
const top = index * rowHeight
const bottom = top + rowHeight
const height = element.clientHeight
let next: number | null = null
if (align === 'center') next = top - Math.max(0, (height - rowHeight) / 2)
// Keep the row at the same distance from the viewport top it had before (prepend anchoring).
else if (align === 'offset') next = top - offset
else if (align === 'end') next = bottom - height
else if (top < element.scrollTop) next = top
else if (bottom > element.scrollTop + height) next = bottom - height
if (next !== null) element.scrollTop = Math.max(0, next)
}, [containerRef, rowHeight])
return {
...computeFixedRowWindow(viewport.scrollTop, viewport.height || 600, rowHeight, count, overscan),
scrollTop: viewport.scrollTop,
viewportHeight: viewport.height || 600,
scrollToIndex,
}
}
@@ -0,0 +1,131 @@
import { describe, expect, it } from 'vitest'
import { mergeRowPieces, mergeRows } from './mergeRows'
import { buildTrajectoryItems, computeTotals, orderTrajectoryRows, rowDurationMs, wallClockMs } from './viewModel'
import { makeRow } from './trajectoryFixtures'
const NO_OPTIONS = { collapsedTurns: new Set<number>(), hideTools: false, query: '' }
describe('mergeRows', () => {
it('pairs a tool_use piece with a later tool_result piece into one row', () => {
const use = makeRow({ id: 't:1', kind: 'tool', ts: '2026-10-04T10:00:01.000Z', toolName: 'Bash', inputPreview: '{"command":"ls"}', loc: [[100, 150]], parentId: 'a:m1' })
const result = makeRow({ id: 't:1', kind: 'tool', ts: '2026-10-04T10:00:04.000Z', resultTs: '2026-10-04T10:00:04.000Z', resultPreview: 'a.txt', partial: true, loc: [[300, 340]] })
const merged = mergeRows(new Map([[use.id, use]]), [result]).get('t:1')!
expect(merged.ts).toBe(use.ts)
expect(merged.toolName).toBe('Bash')
expect(merged.resultPreview).toBe('a.txt')
expect(merged.partial).toBeUndefined()
expect(merged.loc).toEqual([[100, 150], [300, 340]])
expect(rowDurationMs(merged)).toBe(3000)
})
it('is idempotent when the same piece is delivered twice', () => {
const piece = makeRow({ id: 'a:m1', kind: 'assistant', preview: 'hello', toolOnly: false, loc: [[10, 20]] })
const once = mergeRows(new Map(), [piece])
const twice = mergeRows(once, [piece])
expect(twice.get('a:m1')).toEqual(once.get('a:m1'))
})
it('keeps the largest usage of any piece, because desktop transcripts put the final usage on the last block only', () => {
const early = makeRow({ id: 'a:m2', kind: 'assistant', ts: '2026-10-04T10:00:02.000Z', usage: { input: 900, output: 0, cacheRead: 0, cacheWrite: 0 }, loc: [[100, 110]] })
const late = makeRow({ id: 'a:m2', kind: 'assistant', ts: '2026-10-04T10:00:05.000Z', usage: { input: 900, output: 770, cacheRead: 4000, cacheWrite: 12 }, partial: true, loc: [[200, 210]] })
// Whichever page arrives first, the merged row reports the final usage.
expect(mergeRowPieces(early, late).usage).toEqual({ input: 900, output: 770, cacheRead: 4000, cacheWrite: 12 })
expect(mergeRowPieces(late, early).usage).toEqual({ input: 900, output: 770, cacheRead: 4000, cacheWrite: 12 })
})
it('keeps the earlier text and ANDs toolOnly across assistant pieces split by a page boundary', () => {
const later = makeRow({ id: 'a:m1', kind: 'assistant', ts: '2026-10-04T10:00:05.000Z', preview: '', toolOnly: true, partial: true, loc: [[500, 560]] })
const earlier = makeRow({ id: 'a:m1', kind: 'assistant', ts: '2026-10-04T10:00:02.000Z', startTs: '2026-10-04T10:00:00.000Z', preview: 'Let me check', toolOnly: false, loc: [[400, 480]] })
const merged = mergeRowPieces(later, earlier)
expect(merged.ts).toBe(earlier.ts)
expect(merged.endTs).toBe(later.ts)
expect(merged.preview).toBe('Let me check')
expect(merged.toolOnly).toBe(false)
expect(merged.startTs).toBe(earlier.startTs)
expect(rowDurationMs(merged)).toBe(5000)
})
})
describe('orderTrajectoryRows', () => {
it('keeps file order and puts a snapshot row between the prompt and the response it was sent with', () => {
const user = makeRow({ id: 'u:1', kind: 'user', ts: '2026-10-04T10:00:00.000Z', loc: [[0, 10]] })
const assistant = makeRow({ id: 'a:m1', kind: 'assistant', ts: '2026-10-04T10:00:03.000Z', loc: [[20, 30]] })
const tool = makeRow({ id: 't:1', kind: 'tool', ts: '2026-10-04T10:00:03.000Z', loc: [[20, 30]] })
const snapshot = makeRow({ id: 's:main:0', kind: 'system', turn: null, ts: '2026-10-04T10:00:01.000Z', loc: [], snapshot: { scope: 'main', change: 'initial' } })
const ordered = orderTrajectoryRows(new Map([[tool.id, tool], [assistant.id, assistant], [user.id, user]]), [snapshot])
expect(ordered.map((row) => row.id)).toEqual(['u:1', 's:main:0', 'a:m1', 't:1'])
// The snapshot takes the turn of the response it precedes.
expect(ordered[1]!.turn).toBe(1)
})
it('holds back snapshots that belong to history not loaded yet', () => {
const user = makeRow({ id: 'u:9', kind: 'user', turn: 9, ts: '2026-10-04T12:00:00.000Z', loc: [[9000, 9010]] })
const old = makeRow({ id: 's:main:0', kind: 'system', turn: null, ts: '2026-10-04T09:00:00.000Z', loc: [], snapshot: { scope: 'main', change: 'initial' } })
const recent = makeRow({ id: 's:main:5', kind: 'system', turn: null, ts: '2026-10-04T12:00:01.000Z', loc: [], snapshot: { scope: 'main', change: 'tools' } })
const rows = new Map([[user.id, user]])
expect(orderTrajectoryRows(rows, [old, recent], { hasOlder: true }).map((row) => row.id)).toEqual(['u:9', 's:main:5'])
// Once the head of the file is loaded, the initial prompt has its place again.
expect(orderTrajectoryRows(rows, [old, recent], { hasOlder: false }).map((row) => row.id)).toEqual(['s:main:0', 'u:9', 's:main:5'])
})
})
describe('wallClockMs', () => {
it('merges overlapping calls instead of summing them', () => {
const at = (s: number) => new Date(Date.UTC(2026, 9, 4, 10, 0, s)).toISOString()
const parallel = [
makeRow({ id: 't:a', kind: 'tool', ts: at(0), resultTs: at(10) }),
makeRow({ id: 't:b', kind: 'tool', ts: at(2), resultTs: at(8) }),
makeRow({ id: 't:c', kind: 'tool', ts: at(20), resultTs: at(25) }),
]
expect(wallClockMs(parallel)).toBe(15_000)
})
})
describe('buildTrajectoryItems', () => {
const rows = [
makeRow({ id: 'u:1', kind: 'user', turn: 1, preview: 'build it' }),
makeRow({ id: 'a:1', kind: 'assistant', turn: 1 }),
makeRow({ id: 't:1', kind: 'tool', turn: 1, parentId: 'a:1', toolName: 'Bash', inputPreview: 'npm install' }),
makeRow({ id: 't:2', kind: 'tool', turn: 1, parentId: 'a:1', toolName: 'Read' }),
makeRow({ id: 'u:2', kind: 'user', turn: 2, preview: 'now test it' }),
makeRow({ id: 'a:2', kind: 'assistant', turn: 2, preview: 'done' }),
]
it('marks the first row of every turn', () => {
const items = buildTrajectoryItems(rows, NO_OPTIONS)
expect(items.filter((item) => item.type === 'row' && item.turnStart).map((item) => item.key)).toEqual(['u:1', 'u:2'])
})
it('folds a collapsed turn to its prompt plus a summary of what it hides', () => {
const items = buildTrajectoryItems(rows, { ...NO_OPTIONS, collapsedTurns: new Set([1]) })
expect(items.map((item) => item.key)).toEqual(['u:1', 'summary:1', 'u:2', 'a:2'])
const summary = items[1]!
expect(summary.type === 'summary' && summary.hiddenRows).toBe(3)
expect(summary.type === 'summary' && summary.toolCount).toBe(2)
})
it('hides tool rows and summarizes them, failures included, on the assistant that issued them', () => {
const withFailure = rows.map((row) => (row.id === 't:1' ? { ...row, isError: true } : row))
const items = buildTrajectoryItems(withFailure, { ...NO_OPTIONS, hideTools: true })
expect(items.map((item) => item.key)).toEqual(['u:1', 'a:1', 'u:2', 'a:2'])
const assistant = items[1]!
expect(assistant.type === 'row' && assistant.hiddenTools).toEqual({ count: 2, errors: 1, names: [['Bash', 1], ['Read', 1]] })
})
it('searches across folded turns and hidden tools with AND semantics', () => {
const items = buildTrajectoryItems(rows, { collapsedTurns: new Set([1]), hideTools: true, query: 'bash npm' })
expect(items.map((item) => item.key)).toEqual(['t:1'])
})
})
describe('computeTotals', () => {
it('sums usage over assistant rows and counts turns, steps and tools', () => {
const totals = computeTotals([
makeRow({ id: 'u:1', kind: 'user', turn: 1 }),
makeRow({ id: 'a:1', kind: 'assistant', turn: 1, usage: { input: 10, output: 5, cacheRead: 100, cacheWrite: 20 } }),
makeRow({ id: 't:1', kind: 'tool', turn: 1, isError: true }),
makeRow({ id: 'a:2', kind: 'assistant', turn: 2, usage: { input: 1, output: 2, cacheRead: 3, cacheWrite: 4 } }),
])
expect(totals).toEqual({ turns: 2, firstTurn: 1, steps: 2, tools: 1, errors: 1, input: 11, output: 7, cacheRead: 103, cacheWrite: 24 })
})
})
+323
View File
@@ -0,0 +1,323 @@
import type { TrajectoryRow, TrajectoryRowKind } from '../../types/trajectory'
export type TrajectoryLane = 0 | 1 | 2
/** What a hidden run of tool calls did, shown on the response that issued them. */
export type HiddenToolSummary = {
count: number
errors: number
/** `[toolName, calls]`, most frequent first. */
names: Array<[string, number]>
}
export type TrajectoryItem =
| { type: 'row'; key: string; row: TrajectoryRow; turnStart: boolean; hiddenTools: HiddenToolSummary | null }
| { type: 'summary'; key: string; turn: number; hiddenRows: number; toolCount: number; errors: number; durationMs: number }
export type TrajectoryViewOptions = {
/** Turns folded down to their first row plus a summary. */
collapsedTurns: ReadonlySet<number>
/** Hide tool rows; the issuing assistant row shows what they were. */
hideTools: boolean
query: string
}
export type TurnStats = { rows: number; tools: number; errors: number; durationMs: number }
const KIND_RANK: Record<TrajectoryRowKind, number> = {
system: 0,
user: 1,
context: 2,
compact: 3,
assistant: 4,
tool: 5,
}
function timeOf(value: string | undefined): number | null {
if (!value) return null
const ms = Date.parse(value)
return Number.isFinite(ms) ? ms : null
}
function rowInterval(row: TrajectoryRow): [number, number] | null {
if (row.kind === 'tool') {
const start = timeOf(row.ts)
const end = timeOf(row.resultTs)
return start !== null && end !== null && end >= start ? [start, end] : null
}
if (row.kind === 'assistant') {
const start = timeOf(row.startTs ?? row.ts)
const end = timeOf(row.endTs ?? row.ts)
return start !== null && end !== null && end >= start ? [start, end] : null
}
return null
}
/** Wall-clock duration a row represents, or null when it has none. */
export function rowDurationMs(row: TrajectoryRow): number | null {
const interval = rowInterval(row)
return interval ? interval[1] - interval[0] : null
}
/**
* Wall-clock time covered by a set of rows. Parallel tool calls overlap, so
* their durations are merged as intervals instead of summed.
*/
export function wallClockMs(rows: readonly TrajectoryRow[]): number {
const intervals = rows.map(rowInterval).filter((value): value is [number, number] => value !== null)
if (!intervals.length) return 0
intervals.sort((a, b) => a[0] - b[0])
let total = 0
let [start, end] = intervals[0]!
for (let index = 1; index < intervals.length; index++) {
const [nextStart, nextEnd] = intervals[index]!
if (nextStart > end) {
total += end - start
start = nextStart
end = nextEnd
} else if (nextEnd > end) {
end = nextEnd
}
}
return total + (end - start)
}
export function laneForKind(kind: TrajectoryRowKind): TrajectoryLane {
if (kind === 'assistant' || kind === 'compact') return 1
if (kind === 'tool') return 2
return 0
}
/** A tool row whose call record is in history that has not been loaded. */
export function isOrphanTool(row: TrajectoryRow): boolean {
return row.kind === 'tool' && !row.toolName
}
/** A tool row that will not get a result: the result exists, or the call finished without one. */
export function toolHasResult(row: TrajectoryRow): boolean {
return Boolean(row.resultTs || row.resultPreview || row.resultOmittedBytes)
}
/**
* Transcript rows keep file order (their first byte offset). Snapshot rows have
* no offset: each is placed before the first transcript row written after it,
* which puts "system prompt updated" between the prompt and the response it
* was sent with. While older history is still unloaded, snapshots older than
* the first loaded row belong to that history and are held back — placing
* them would file them under the wrong turn.
*/
export function orderTrajectoryRows(
rows: ReadonlyMap<string, TrajectoryRow>,
snapshots: readonly TrajectoryRow[],
options: { hasOlder?: boolean } = {},
): TrajectoryRow[] {
const transcript = [...rows.values()].sort((a, b) => {
const offsetA = a.loc[0]?.[0]
const offsetB = b.loc[0]?.[0]
if (offsetA !== undefined && offsetB !== undefined && offsetA !== offsetB) return offsetA - offsetB
if (offsetA === offsetB && a.kind !== b.kind) return KIND_RANK[a.kind] - KIND_RANK[b.kind]
if (a.ts !== b.ts) return a.ts < b.ts ? -1 : 1
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
})
if (!snapshots.length) return transcript
const firstTs = transcript[0]?.ts
const pending = snapshots
.filter((row) => !options.hasOlder || !firstTs || row.ts >= firstTs)
.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0))
const ordered: TrajectoryRow[] = []
let next = 0
for (const row of transcript) {
while (next < pending.length && pending[next]!.ts < row.ts) {
ordered.push({ ...pending[next]!, turn: row.turn })
next++
}
ordered.push(row)
}
const lastTurn = transcript.length ? transcript[transcript.length - 1]!.turn : null
while (next < pending.length) {
ordered.push({ ...pending[next]!, turn: lastTurn })
next++
}
return ordered
}
export function rowSearchText(row: TrajectoryRow): string {
return [
row.kind,
row.label,
row.preview,
row.toolName,
row.inputSummary,
row.inputPreview,
row.resultPreview,
row.model,
row.attachmentType,
row.toolUseId,
].filter(Boolean).join('\n').toLowerCase()
}
export function matchesQuery(searchText: string, terms: readonly string[]): boolean {
return terms.every((term) => searchText.includes(term))
}
export function queryTerms(query: string): string[] {
return query.toLowerCase().split(/\s+/).filter(Boolean)
}
function summarizeTools(tools: readonly TrajectoryRow[]): HiddenToolSummary {
const counts = new Map<string, number>()
let errors = 0
for (const tool of tools) {
const name = tool.toolName ?? '?'
counts.set(name, (counts.get(name) ?? 0) + 1)
if (tool.isError) errors++
}
return {
count: tools.length,
errors,
names: [...counts].sort((a, b) => b[1] - a[1]),
}
}
/** Per-turn counts for the folded summaries and the sticky turn bar. */
export function computeTurnStats(ordered: readonly TrajectoryRow[]): Map<number, TurnStats> {
const groups = new Map<number, TrajectoryRow[]>()
for (const row of ordered) {
if (row.turn === null) continue
let group = groups.get(row.turn)
if (!group) groups.set(row.turn, (group = []))
group.push(row)
}
const stats = new Map<number, TurnStats>()
for (const [turn, group] of groups) {
stats.set(turn, {
rows: group.length,
tools: group.filter((row) => row.kind === 'tool').length,
errors: group.filter((row) => row.isError).length,
durationMs: wallClockMs(group),
})
}
return stats
}
/**
* Turn the ordered rows into what the table renders. Search ignores folding:
* a match inside a folded turn must still be visible.
*/
export function buildTrajectoryItems(
ordered: readonly TrajectoryRow[],
options: TrajectoryViewOptions,
searchTextOf: (row: TrajectoryRow) => string = rowSearchText,
): TrajectoryItem[] {
const terms = queryTerms(options.query)
const items: TrajectoryItem[] = []
if (terms.length) {
let previousTurn: number | null | undefined
for (const row of ordered) {
if (!matchesQuery(searchTextOf(row), terms)) continue
items.push({ type: 'row', key: row.id, row, turnStart: row.turn !== previousTurn, hiddenTools: null })
previousTurn = row.turn
}
return items
}
const hiddenToolsByParent = new Map<string, TrajectoryRow[]>()
if (options.hideTools) {
for (const row of ordered) {
if (row.kind === 'tool' && row.parentId) {
const list = hiddenToolsByParent.get(row.parentId)
if (list) list.push(row)
else hiddenToolsByParent.set(row.parentId, [row])
}
}
}
let index = 0
while (index < ordered.length) {
const turn = ordered[index]!.turn
let end = index
while (end < ordered.length && ordered[end]!.turn === turn) end++
const group = ordered.slice(index, end)
if (turn !== null && options.collapsedTurns.has(turn) && group.length > 1) {
const head = group.find((row) => row.kind === 'user') ?? group[0]!
items.push({ type: 'row', key: head.id, row: head, turnStart: true, hiddenTools: null })
items.push({
type: 'summary',
key: `summary:${turn}`,
turn,
hiddenRows: group.length - 1,
toolCount: group.filter((row) => row.kind === 'tool').length,
errors: group.filter((row) => row.isError).length,
durationMs: wallClockMs(group),
})
} else {
let first = true
for (const row of group) {
if (options.hideTools && row.kind === 'tool') continue
const hidden = row.kind === 'assistant' ? hiddenToolsByParent.get(row.id) : undefined
items.push({
type: 'row',
key: row.id,
row,
turnStart: first,
hiddenTools: hidden?.length ? summarizeTools(hidden) : null,
})
first = false
}
}
index = end
}
return items
}
export function listTurns(ordered: readonly TrajectoryRow[]): number[] {
const turns: number[] = []
let previous: number | null | undefined
for (const row of ordered) {
if (row.turn !== previous && row.turn !== null) turns.push(row.turn)
previous = row.turn
}
return turns
}
export type TrajectoryTotals = {
turns: number
/** Lowest loaded turn, for "from turn N" when older history is not loaded. */
firstTurn: number | null
steps: number
tools: number
errors: number
input: number
output: number
cacheRead: number
cacheWrite: number
}
export function computeTotals(ordered: readonly TrajectoryRow[]): TrajectoryTotals {
const totals: TrajectoryTotals = { turns: 0, firstTurn: null, steps: 0, tools: 0, errors: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
const turns = new Set<number>()
for (const row of ordered) {
if (row.turn !== null && row.turn > 0) {
turns.add(row.turn)
if (totals.firstTurn === null || row.turn < totals.firstTurn) totals.firstTurn = row.turn
}
if (row.isError) totals.errors++
if (row.kind === 'tool') totals.tools++
if (row.kind === 'assistant') {
totals.steps++
if (row.usage) {
totals.input += row.usage.input
totals.output += row.usage.output
totals.cacheRead += row.usage.cacheRead
totals.cacheWrite += row.usage.cacheWrite
}
}
}
totals.turns = turns.size
return totals
}
export function hasUsage(row: TrajectoryRow): boolean {
const usage = row.usage
return Boolean(usage && (usage.input || usage.output || usage.cacheRead || usage.cacheWrite))
}
@@ -471,11 +471,10 @@ describe('primary-window gate', () => {
const { isPrimaryWorkspaceWindow } = await import('../../main')
expect(isPrimaryWorkspaceWindow('')).toBe(true)
// Pet and trace windows share this origin and load the same entry. The
// The pet window shares this origin and loads the same entry. The
// document is written whole, so a second writer would overwrite the main
// window's state with whatever it hydrated when it opened.
expect(isPrimaryWorkspaceWindow('?petWindow=1')).toBe(false)
expect(isPrimaryWorkspaceWindow('?traceWindow=1&traceSessionId=abc')).toBe(false)
})
it('discards legacy viewed marks without a comparison fingerprint', () => {
-1
View File
@@ -58,7 +58,6 @@ describe('desktop bootstrap', () => {
expect(isPetWindowLocation('?petWindow=1')).toBe(true)
expect(isPetWindowLocation('?petWindow=0')).toBe(false)
expect(isPetWindowLocation('?traceWindow=1')).toBe(false)
})
it('surfaces bootstrap failures in the root element', async () => {
+5 -5
View File
@@ -31,14 +31,14 @@ export function isPetWindowLocation(search = window.location.search): boolean {
/**
* Whether this renderer is the one that owns workspace state.
*
* Every window — main, pet, and each detached trace window — loads this same
* entry. They share one `localStorage` origin, and the workspace document is
* written whole rather than merged per session, so a second writer would
* overwrite the main window's state with whatever it hydrated at open time.
* Every window — main and pet — loads this same entry. They share one
* `localStorage` origin, and the workspace document is written whole rather
* than merged per session, so a second writer would overwrite the main
* window's state with whatever it hydrated at open time.
*/
export function isPrimaryWorkspaceWindow(search = window.location.search): boolean {
const params = new URLSearchParams(search)
return params.get('petWindow') !== '1' && params.get('traceWindow') !== '1'
return params.get('petWindow') !== '1'
}
function loadDesktopBootstrapModules() {
+62 -1
View File
@@ -70,6 +70,12 @@ vi.mock('../components/chat/MessageList', () => ({
),
}))
vi.mock('../components/trajectory/TrajectoryView', () => ({
default: ({ sessionId, visible, running }: { sessionId: string; visible: boolean; running: boolean }) => (
<div data-testid="trajectory-view" data-session-id={sessionId} data-visible={visible ? 'true' : 'false'} data-running={running ? 'true' : 'false'} />
),
}))
vi.mock('../components/chat/ChatInput', () => ({
ChatInput: ({ compact, variant, sessionId, visible }: { compact?: boolean; variant?: string; sessionId?: string; visible?: boolean }) => (
<div data-testid="chat-input" data-compact={compact ? 'true' : 'false'} data-variant={variant} data-session-id={sessionId} data-visible={visible ? 'true' : 'false'} />
@@ -127,6 +133,7 @@ import { useSettingsStore } from '../stores/settingsStore'
import { useTabStore } from '../stores/tabStore'
import { useTeamStore } from '../stores/teamStore'
import { useActivityPanelStore } from '../stores/activityPanelStore'
import { useTrajectoryViewStore } from '../stores/trajectoryViewStore'
import {
WORKSPACE_BOTTOM_DEFAULT_HEIGHT,
WORKSPACE_BOTTOM_MAX_HEIGHT,
@@ -177,6 +184,60 @@ afterEach(() => {
useWorkspaceStore.setState(useWorkspaceStore.getInitialState(), true)
useActivityPanelStore.setState(useActivityPanelStore.getInitialState(), true)
useCLITaskStore.setState(useCLITaskStore.getInitialState(), true)
useTrajectoryViewStore.setState({ modes: {}, opened: {}, nav: null })
})
describe('ActiveSession trajectory view', () => {
function seedSession(id: string) {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ activeTabId: id, tabs: [{ sessionId: id, title: 'Main', type: 'session', status: 'idle' }] })
useSessionStore.setState({ sessions: [{ id, title: 'Main', messageCount: 1, createdAt: '', modifiedAt: '', projectPath: '/repo', workDir: '/repo', workDirExists: true }] })
useChatStore.setState({ sessions: { [id]: { ...createDefaultSessionState(), connectionState: 'connected', historyStatus: 'ready', historyHydrated: true, messages: [{ id: 'm', type: 'assistant_text', content: 'main', timestamp: 1 }] } } })
}
it('swaps the chat body for the trajectory while keeping the chat and composer mounted', async () => {
const id = 'trajectory-session'
seedSession(id)
render(<ActiveSession sessionId={id} />)
const list = screen.getByTestId('message-list')
const input = screen.getByTestId('chat-input')
// Never mounted (so never fetched) until the user opens it.
expect(screen.queryByTestId('trajectory-view')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
const trajectory = await screen.findByTestId('trajectory-view')
expect(trajectory).toHaveAttribute('data-visible', 'true')
expect(trajectory).toHaveAttribute('data-session-id', id)
expect(screen.getByTestId('message-list')).toBe(list)
expect(list.parentElement).toHaveClass('hidden')
expect(screen.getByTestId('chat-input')).toBe(input)
fireEvent.click(screen.getByRole('tab', { name: 'Chat' }))
expect(screen.getByTestId('trajectory-view')).toBe(trajectory)
expect(trajectory).toHaveAttribute('data-visible', 'false')
expect(screen.getByTestId('session-trajectory-panel')).toHaveClass('hidden')
expect(list.parentElement).not.toHaveClass('hidden')
})
it('opens the trajectory when a chat tool card asks to reveal a row there', async () => {
const id = 'reveal-session'
seedSession(id)
render(<ActiveSession sessionId={id} />)
act(() => useTrajectoryViewStore.getState().revealInTrajectory(id, 't:toolu_1'))
expect(await screen.findByTestId('trajectory-view')).toHaveAttribute('data-visible', 'true')
expect(screen.getByRole('tab', { name: 'Trajectory' })).toHaveAttribute('aria-selected', 'true')
})
it('offers no trajectory switch on mobile', () => {
viewportMocks.isMobile = true
const id = 'mobile-trajectory'
seedSession(id)
useTrajectoryViewStore.setState({ modes: { [id]: 'trajectory' }, opened: { [id]: true }, nav: null })
render(<ActiveSession sessionId={id} />)
expect(screen.queryByRole('tab', { name: 'Trajectory' })).not.toBeInTheDocument()
expect(screen.queryByTestId('trajectory-view')).not.toBeInTheDocument()
expect(screen.getByTestId('message-list').parentElement).not.toHaveClass('hidden')
})
})
describe('ActiveSession task polling', () => {
@@ -512,7 +573,7 @@ describe('ActiveSession task polling', () => {
})
const tokenBadge = screen.getByTitle(/cache read 4,971,000.*cache write 10,000/i)
expect(tokenBadge).toHaveTextContent('5.1m tokens incl. cache')
expect(tokenBadge).toHaveTextContent('5.1M tokens incl. cache')
})
it('shows the worktree name in the header and reveals its directory on focus', async () => {
+46 -16
View File
@@ -1,5 +1,5 @@
import { AgentTeamsPlanCard } from '@/components/agentTeams/AgentTeamsPlanCard'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { RefObject } from 'react'
import { ArrowLeft, GitFork, Target, MessageCircleQuestion } from 'lucide-react'
import { IconButton } from '@/components/ui/IconButton'
@@ -9,7 +9,6 @@ import {
SETTINGS_TAB_ID,
TEAM_TAB_PREFIX,
TERMINAL_TAB_PREFIX,
TRACE_TAB_PREFIX,
WORKBENCH_TAB_PREFIX,
useTabStore,
type TabType,
@@ -31,6 +30,9 @@ import { Tooltip } from '@/components/ui/Tooltip'
import { BrandSeal } from '@/components/composite/BrandSeal'
import { MessageList } from '../components/chat/MessageList'
import { ChatInput } from '../components/chat/ChatInput'
import { TrajectoryViewSwitch } from '../components/trajectory/TrajectoryViewSwitch'
import { TrajectoryLinkContext } from '../components/trajectory/TrajectoryLinkContext'
import { useTrajectoryViewStore } from '../stores/trajectoryViewStore'
import {
SessionChatHeader,
SessionChatSurface,
@@ -83,6 +85,9 @@ const WORKSPACE_RESIZE_STEP = 32
const TERMINAL_RESIZE_STEP = 24
const EMPTY_DISMISSED_BACKGROUND_TASK_KEYS: readonly string[] = []
// Loaded on first switch to 轨迹, so sessions that never open it pay nothing.
const TrajectoryView = lazy(() => import('../components/trajectory/TrajectoryView'))
function isSessionTabState(activeTabId: string | null, activeTabType: TabType | null | undefined) {
if (!activeTabId) return false
if (activeTabType === 'session') return true
@@ -90,7 +95,6 @@ function isSessionTabState(activeTabId: string | null, activeTabType: TabType |
return activeTabId !== SETTINGS_TAB_ID &&
activeTabId !== SCHEDULED_TAB_ID &&
!activeTabId.startsWith(TERMINAL_TAB_PREFIX) &&
!activeTabId.startsWith(TRACE_TAB_PREFIX) &&
!activeTabId.startsWith(WORKBENCH_TAB_PREFIX) &&
!activeTabId.startsWith(TEAM_TAB_PREFIX)
}
@@ -345,6 +349,11 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
: EMPTY_DISMISSED_BACKGROUND_TASK_KEYS,
)
const chatState = sessionState?.chatState ?? 'idle'
const trajectoryMode = useTrajectoryViewStore((state) => activeTabId ? state.modes[activeTabId] === 'trajectory' : false)
const trajectoryOpened = useTrajectoryViewStore((state) => activeTabId ? state.opened[activeTabId] === true : false)
const trajectoryLink = useMemo(() => (activeTabId ? { sessionId: activeTabId } : null), [activeTabId])
// Mobile has no header to switch from, and a ledger plus detail pane does not fit a phone.
const showTrajectory = trajectoryMode && !isMobileLayout
const tokenUsage = sessionState?.tokenUsage ?? { input_tokens: 0, output_tokens: 0 }
const hasRunningBackgroundTasks = hasAnyRunningBackgroundTasks(sessionState?.backgroundAgentTasks)
const stoppingBackgroundTaskIds = sessionState?.stoppingBackgroundTaskIds
@@ -863,8 +872,11 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
title={headerTitle}
compact={showRightPanel}
metadata={headerMetadata}
actions={<IconButton icon={<MessageCircleQuestion size={18} />} label={t('sideChat.title')}
pressed={sideChatOpen} onClick={() => void openSideChat(activeTabId)} />}
actions={<>
{activeTabId && <TrajectoryViewSwitch sessionId={activeTabId} />}
<IconButton icon={<MessageCircleQuestion size={18} />} label={t('sideChat.title')}
pressed={sideChatOpen} onClick={() => void openSideChat(activeTabId)} />
</>}
>
{session && getSessionWorkspaceState(session) !== 'available' && (
<div className={`mt-2 inline-flex max-w-full items-center gap-2 rounded-[var(--radius-md)] border px-3 py-1.5 text-[11px] ${
@@ -900,17 +912,34 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
</SessionChatHeader>
)}
{isHistoryLoading ? (
<div className="flex flex-1 items-center justify-center p-8">
<LoadingState label={t('common.loading')} variant="inline" size="md" />
{/* The chat stays mounted under 轨迹 so its scroll position, measured heights and draft survive the switch. */}
<div className={showTrajectory ? 'hidden' : 'contents'}>
{isHistoryLoading ? (
<div className="flex flex-1 items-center justify-center p-8">
<LoadingState label={t('common.loading')} variant="inline" size="md" />
</div>
) : historyError ? (
<div role="alert" className="flex flex-1 items-center justify-center p-8 text-sm text-[var(--color-error)]">
{historyError}
</div>
) : (
<TrajectoryLinkContext.Provider value={isMobileLayout ? null : trajectoryLink}>
<MessageList sessionId={activeTabId ?? undefined} compact={showRightPanel} mobileLayout={isMobileLayout} />
</TrajectoryLinkContext.Provider>
)}
</div>
{trajectoryOpened && activeTabId && !isMobileLayout ? (
<div className={showTrajectory ? 'flex min-h-0 flex-1 flex-col' : 'hidden'} data-testid="session-trajectory-panel">
<Suspense fallback={<LoadingState label={t('common.loading')} variant="inline" size="md" className="flex-1" />}>
<TrajectoryView
sessionId={activeTabId}
visible={showTrajectory && active}
running={chatState !== 'idle'}
activityKey={sessionState?.messages.length ?? 0}
/>
</Suspense>
</div>
) : historyError ? (
<div role="alert" className="flex flex-1 items-center justify-center p-8 text-sm text-[var(--color-error)]">
{historyError}
</div>
) : (
<MessageList sessionId={activeTabId ?? undefined} compact={showRightPanel} mobileLayout={isMobileLayout} />
)}
) : null}
</>
)}
@@ -936,7 +965,8 @@ export function ActiveSession({ sessionId, active = true }: { sessionId?: string
sessionId={activeTabId ?? undefined}
visible={active}
variant={isEmpty && !showRightPanel ? 'hero' : 'default'}
compact={showRightPanel}
// The ledger needs the height more than the composer needs its full chrome.
compact={showRightPanel || showTrajectory}
/>
{hasBottomTerminals && activeTabId ? (
+2 -5
View File
@@ -14,7 +14,6 @@ import { ComputerUseSettings } from './ComputerUseSettings'
import { McpSettings } from './McpSettings'
import { TerminalSettings } from './TerminalSettings'
import { DiagnosticsSettings } from './DiagnosticsSettings'
import { TraceList } from './TraceList'
import { ActivitySettings } from './ActivitySettings'
import { MemorySettings } from './MemorySettings'
import { PetSettings } from '../features/pets/PetSettings'
@@ -81,7 +80,6 @@ export function DesktopSettings() {
<TabButton icon="pets" label={t('settings.tab.pets')} active={activeTab === 'pets'} onClick={() => setActiveTab('pets')} />
<TabButton icon="mouse" label={t('settings.tab.computerUse')} active={activeTab === 'computerUse'} onClick={() => setActiveTab('computerUse')} />
<TabButton icon="monitoring" label={t('settings.tab.activity')} active={activeTab === 'activity'} onClick={() => setActiveTab('activity')} />
<TabButton icon="account_tree" label={t('settings.tab.trace')} active={activeTab === 'trace'} onClick={() => setActiveTab('trace')} />
<TabButton icon="monitor_heart" label={t('settings.tab.diagnostics')} active={activeTab === 'diagnostics'} onClick={() => setActiveTab('diagnostics')} />
</div>
<div className="mt-2 border-t border-[var(--color-border-separator)] pt-2">
@@ -89,8 +87,8 @@ export function DesktopSettings() {
</div>
</div>
{/* Tab content; trace embeds a full-bleed page that manages its own scroll */}
<div className={activeTab === 'trace' ? 'flex-1 flex min-h-0 flex-col overflow-hidden' : 'flex-1 overflow-y-auto px-9 py-8'}>
{/* Tab content */}
<div className="flex-1 overflow-y-auto px-9 py-8">
{activeTab === 'providers' && <ProviderSettings />}
{activeTab === 'activity' && <ActivitySettings />}
{activeTab === 'general' && <GeneralSettings />}
@@ -105,7 +103,6 @@ export function DesktopSettings() {
{activeTab === 'plugins' && <PluginSettings />}
{activeTab === 'pets' && <PetSettings />}
{activeTab === 'computerUse' && <ComputerUseSettings />}
{activeTab === 'trace' && <TraceList />}
{activeTab === 'diagnostics' && <DiagnosticsSettings />}
{activeTab === 'about' && <AboutSettings />}
</div>
+15 -19
View File
@@ -2,26 +2,14 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('../api/traces', () => ({
tracesApi: {
list: vi.fn().mockResolvedValue({
total: 0,
storageDir: '/tmp/cc-haha/traces',
settings: { enabled: true, storageDir: '/tmp/cc-haha/traces' },
traces: [],
}),
deleteSession: vi.fn(),
},
}))
import { DesktopSettings as Settings } from './Settings'
import { useSettingsStore } from '../stores/settingsStore'
import { useUIStore } from '../stores/uiStore'
/**
* The rail is a scroll container taller than its viewport, and Settings
* remounts every time the tab is re-entered — notably when a trace tab's "back
* to list" walks the user here. These cover the two halves of landing
* remounts every time the tab is re-entered — notably when another surface
* sends the user to a specific section. These cover the two halves of landing
* correctly: the right section is shown, and its rail entry is in view.
*/
describe('Settings section navigation', () => {
@@ -44,27 +32,35 @@ describe('Settings section navigation', () => {
})
it('opens the section a pending request asked for and clears the request', async () => {
useUIStore.setState({ pendingSettingsTab: 'trace' })
useUIStore.setState({ pendingSettingsTab: 'diagnostics' })
render(<Settings />)
expect(await screen.findByRole('heading', { level: 1, name: 'Trace list' })).toBeInTheDocument()
expect(useUIStore.getState().activeSettingsTab).toBe('trace')
await waitFor(() => {
expect(useUIStore.getState().activeSettingsTab).toBe('diagnostics')
})
expect(useUIStore.getState().pendingSettingsTab).toBeNull()
expect(screen.getByRole('button', { name: 'Diagnostics', current: 'page' })).toBeInTheDocument()
})
it('brings the selected rail entry into view on mount', async () => {
useUIStore.setState({ activeSettingsTab: 'trace' })
useUIStore.setState({ activeSettingsTab: 'diagnostics' })
render(<Settings />)
await waitFor(() => {
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'nearest' })
})
const railEntry = screen.getByRole('button', { name: 'Trace', current: 'page' })
const railEntry = screen.getByRole('button', { name: 'Diagnostics', current: 'page' })
expect(railEntry).toBeInTheDocument()
})
it('no longer offers a Trace section; per-session traces live in the chat', () => {
render(<Settings />)
expect(screen.queryByRole('button', { name: 'Trace' })).not.toBeInTheDocument()
})
it('follows the selection when another section is picked', () => {
render(<Settings />)
scrollIntoView.mockClear()
-339
View File
@@ -1,339 +0,0 @@
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import '@testing-library/jest-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { TraceList } from './TraceList'
import { tracesApi } from '../api/traces'
import { SETTINGS_TAB_ID, useTabStore } from '../stores/tabStore'
import { useSettingsStore } from '../stores/settingsStore'
import type { TraceSessionList } from '../types/trace'
vi.mock('../api/traces', () => ({
tracesApi: {
list: vi.fn(),
deleteSession: vi.fn(),
},
}))
const { openTraceWindowMock, hostState } = vi.hoisted(() => ({
openTraceWindowMock: vi.fn(async () => {}),
/** Flipped per-test to model H5/browser, where there is no native window API. */
hostState: { hasTraceHost: true },
}))
vi.mock('../lib/desktopHost', async (importOriginal) => {
const actual = await importOriginal<typeof import('../lib/desktopHost')>()
return {
...actual,
getDesktopHost: () => ({
...actual.getDesktopHost(),
trace: hostState.hasTraceHost ? { openWindow: openTraceWindowMock } : undefined,
}),
}
})
const traceList: TraceSessionList = {
total: 1,
storageDir: '/tmp/cc-haha/traces',
settings: {
enabled: true,
storageDir: '/tmp/cc-haha/traces',
},
traces: [{
sessionId: 'session-trace-list',
session: {
id: 'session-trace-list',
title: 'Debug stuck agent',
projectPath: '/tmp/project',
workDir: '/tmp/project',
},
summary: {
apiCalls: 3,
failedCalls: 1,
totalDurationMs: 4715,
totalInputTokens: 1200,
totalOutputTokens: 300,
models: [
{ model: 'claude-sonnet-4-5-20250929', calls: 2 },
{ model: 'claude-haiku-4-5-20251001', calls: 1 },
{ model: 'gpt-5.5', calls: 1 },
],
updatedAt: '2026-06-09T15:03:40.010Z',
},
fileSize: 2048,
fileUpdatedAt: '2026-06-09T15:03:40.010Z',
}],
}
const secondTraceList: TraceSessionList = {
...traceList,
traces: [{
...traceList.traces[0]!,
sessionId: 'session-trace-second-page',
session: {
id: 'session-trace-second-page',
title: 'Second trace session',
projectPath: '/tmp/second-project',
workDir: '/tmp/second-project',
},
summary: {
...traceList.traces[0]!.summary,
apiCalls: 1,
failedCalls: 0,
models: [{ model: 'gpt-5.5', calls: 1 }],
},
}],
}
async function findTraceRow(title: RegExp) {
return await screen.findByRole('listitem', { name: title })
}
describe('TraceList', () => {
beforeEach(() => {
useSettingsStore.setState({ locale: 'en' })
useTabStore.setState({ tabs: [], activeTabId: null })
hostState.hasTraceHost = true
vi.mocked(tracesApi.list).mockResolvedValue(traceList)
vi.mocked(tracesApi.deleteSession).mockResolvedValue({ sessionId: 'session-trace-list', deleted: true })
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
useTabStore.setState({ tabs: [], activeTabId: null })
useSettingsStore.setState({ locale: 'en' })
})
it('keeps one poll in flight and aborts it when the settings page closes', async () => {
vi.useFakeTimers()
try {
let signal: AbortSignal | undefined
vi.mocked(tracesApi.list).mockResolvedValueOnce(traceList).mockImplementationOnce((_query, options) => {
signal = options?.signal
return new Promise(() => {})
})
const view = render(<TraceList />)
await act(async () => { await Promise.resolve() })
await act(async () => { await vi.advanceTimersByTimeAsync(20_000) })
expect(tracesApi.list).toHaveBeenCalledTimes(2)
expect(signal?.aborted).toBe(false)
view.unmount()
expect(signal?.aborted).toBe(true)
} finally {
vi.useRealTimers()
}
})
it('aborts an obsolete search and ignores its late response', async () => {
let firstSignal: AbortSignal | undefined
let resolveFirst!: (data: TraceSessionList) => void
vi.mocked(tracesApi.list).mockImplementationOnce((_query, options) => {
firstSignal = options?.signal
return new Promise((resolve) => { resolveFirst = resolve })
}).mockResolvedValueOnce(secondTraceList)
render(<TraceList />)
fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'second' } })
await findTraceRow(/Second trace session/)
expect(firstSignal?.aborted).toBe(true)
await act(async () => { resolveFirst(traceList) })
expect(screen.queryByRole('listitem', { name: /Debug stuck agent/ })).not.toBeInTheDocument()
})
it('renders rows with title, model chips, failure count and metrics', async () => {
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
expect(tracesApi.list).toHaveBeenCalledWith({ limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
// header: storage dir + collection badge + aggregate chips
expect(screen.getByText('/tmp/cc-haha/traces')).toBeInTheDocument()
expect(screen.getByText('Collecting')).toBeInTheDocument()
expect(screen.getByText('Sessions')).toBeInTheDocument()
// row line 1: model chips use short names, capped at 2 with a "+N" overflow chip
expect(within(row).getByText('sonnet-4-5')).toBeInTheDocument()
expect(within(row).getByText('haiku-4-5')).toBeInTheDocument()
expect(within(row).getByText('+1')).toBeInTheDocument()
expect(within(row).queryByText('gpt-5.5')).not.toBeInTheDocument()
expect(within(row).getByText('sonnet-4-5')).toHaveAttribute('title', 'claude-sonnet-4-5-20250929 x2')
// row line 1: failed-call indicator
expect(within(row).getByTitle('Failed')).toHaveTextContent('1')
// row line 2: short session id + project path
expect(within(row).getByText('session-')).toBeInTheDocument()
expect(within(row).getByText('/tmp/project')).toBeInTheDocument()
// right metrics: calls / duration / compact tokens
expect(within(row).getByText('3')).toBeInTheDocument()
// '4.71s', not '4.7s': the list used to carry its own formatter with one
// decimal. It now shares formatDurationMs with the detail view, which is the
// whole point — see the minutes case below.
expect(within(row).getByText('4.71s')).toBeInTheDocument()
expect(within(row).getByText('1.5k')).toBeInTheDocument()
})
// Regression: the list had a private formatDuration that stopped at seconds, so a
// 12-minute session read "739.0s" here while the detail view — same field, same
// t('trace.modelTime') label, shared formatter — read "12m 19s". Anything over a
// minute exposes the fork, which is why no existing fixture caught it.
it('renders a duration over a minute the same way the detail view does', async () => {
vi.mocked(tracesApi.list).mockResolvedValue({
...traceList,
traces: [{
...traceList.traces[0]!,
summary: { ...traceList.traces[0]!.summary, totalDurationMs: 739_000 },
}],
})
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
expect(within(row).getByText('12m 19s')).toBeInTheDocument()
})
it('opens a trace tab when the row is clicked or activated via keyboard', async () => {
render(<TraceList />)
fireEvent.click(await screen.findByText('Debug stuck agent'))
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
const traceTab = useTabStore.getState().tabs.find((tab) => tab.type === 'trace')
expect(traceTab?.traceSessionId).toBe('session-trace-list')
// Titled with the session itself — the tab bar's trace glyph carries the
// type, so a "Model trace: " prefix would only eat the visible width.
expect(traceTab?.title).toBe('Debug stuck agent')
useTabStore.setState({ tabs: [], activeTabId: null })
fireEvent.keyDown(within(await findTraceRow(/Debug stuck agent/)).getByRole('button', { name: /Debug stuck agent/ }), { key: 'Enter' })
expect(useTabStore.getState().activeTabId).toBe('__trace__session-trace-list')
})
it('runs hover actions without triggering the row click', async () => {
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
expect(row).not.toHaveAttribute('role', 'button')
fireEvent.click(within(row).getByRole('button', { name: 'Open in separate window' }))
expect(openTraceWindowMock).toHaveBeenCalledWith('session-trace-list')
expect(useTabStore.getState().activeTabId).toBeNull()
// The row actions cover only what a row click cannot express. A separate
// "open" button would just duplicate the click and crowd the hover strip.
expect(within(row).getAllByRole('button').map((button) => button.getAttribute('aria-label')))
.toEqual([null, 'Open in separate window', 'Delete trace'])
})
it('opens a browser tab instead of a dead button when there is no desktop shell', async () => {
hostState.hasTraceHost = false
const openSpy = vi.spyOn(window, 'open').mockReturnValue(null)
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
fireEvent.click(within(row).getByRole('button', { name: 'Open in separate window' }))
expect(openTraceWindowMock).not.toHaveBeenCalled()
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining('traceWindow=1'),
'_blank',
'noopener,noreferrer',
)
expect(openSpy.mock.calls[0]?.[0]).toContain('traceSessionId=session-trace-list')
openSpy.mockRestore()
})
it('requires confirmation before deleting a trace session', async () => {
vi.mocked(tracesApi.list)
.mockResolvedValueOnce(traceList)
.mockResolvedValueOnce({ ...traceList, traces: [], total: 0 })
render(<TraceList />)
const row = await findTraceRow(/Debug stuck agent/)
fireEvent.click(within(row).getByRole('button', { name: 'Delete trace' }))
expect(tracesApi.deleteSession).not.toHaveBeenCalled()
expect(screen.getByText('Delete trace data for "Debug stuck agent"? Chat history is not deleted.')).toBeInTheDocument()
fireEvent.click(within(screen.getByRole('dialog', { name: 'Delete trace session' })).getByRole('button', { name: 'Delete' }))
await waitFor(() => {
expect(tracesApi.deleteSession).toHaveBeenCalledWith('session-trace-list')
})
await waitFor(() => {
expect(screen.queryByText('Debug stuck agent')).not.toBeInTheDocument()
})
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(useTabStore.getState().activeTabId).toBeNull()
})
it('opens General settings from the trace settings button', async () => {
render(<TraceList />)
fireEvent.click(await screen.findByRole('button', { name: 'Trace settings' }))
expect(useTabStore.getState().activeTabId).toBe(SETTINGS_TAB_ID)
expect(useTabStore.getState().tabs.find((tab) => tab.sessionId === SETTINGS_TAB_ID)?.type).toBe('settings')
})
it('announces a failed load and retries from inside the error', async () => {
vi.mocked(tracesApi.list)
.mockRejectedValueOnce(new Error('trace store unreachable'))
.mockResolvedValueOnce(traceList)
render(<TraceList />)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('trace store unreachable')
fireEvent.click(within(alert).getByRole('button', { name: 'Retry' }))
expect(await screen.findByText('/tmp/cc-haha/traces')).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it('loads additional trace pages instead of fetching all rows at once', async () => {
vi.mocked(tracesApi.list)
.mockResolvedValueOnce({ ...traceList, total: 2 })
.mockResolvedValueOnce({ ...secondTraceList, total: 2 })
render(<TraceList />)
expect(await screen.findByText('Debug stuck agent')).toBeInTheDocument()
expect(screen.getByText('Showing 1 of 2')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Load more' }))
expect(await screen.findByText('Second trace session')).toBeInTheDocument()
expect(screen.getByText('Showing 2 of 2')).toBeInTheDocument()
expect(tracesApi.list).toHaveBeenNthCalledWith(1, { limit: 50, offset: 0, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
expect(tracesApi.list).toHaveBeenNthCalledWith(2, { limit: 50, offset: 1, query: '' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
it('sends title search text to the trace list API', async () => {
render(<TraceList />)
await screen.findByText('Debug stuck agent')
fireEvent.change(screen.getByPlaceholderText('Search title, session ID, or project path'), {
target: { value: 'stuck agent' },
})
await waitFor(() => {
expect(tracesApi.list).toHaveBeenLastCalledWith({ limit: 50, offset: 0, query: 'stuck agent' }, expect.objectContaining({ signal: expect.any(AbortSignal) }))
})
})
it('shows the paused badge when capture is disabled', async () => {
vi.mocked(tracesApi.list).mockResolvedValue({
...traceList,
settings: { enabled: false, storageDir: '/tmp/cc-haha/traces' },
})
render(<TraceList />)
expect(await screen.findByText('Paused')).toBeInTheDocument()
expect(screen.queryByText('Collecting')).not.toBeInTheDocument()
})
})
-486
View File
@@ -1,486 +0,0 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { KeyboardEvent } from 'react'
import { ExternalLink, RefreshCw, Trash2, Workflow } from 'lucide-react'
import { tracesApi } from '../api/traces'
import { useTranslation } from '../i18n'
import { Badge } from '@/components/ui/Badge'
import { Button } from '@/components/ui/Button'
import { IconButton } from '@/components/ui/IconButton'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { EmptyState } from '@/components/ui/EmptyState'
import { ErrorState } from '@/components/ui/ErrorState'
import { SearchField } from '@/components/ui/SearchField'
import { formatDurationMs, formatTokenCount } from '../lib/trace/formatters'
import { getDesktopHost } from '../lib/desktopHost'
import { buildTraceWindowUrl } from '../lib/traceLaunch'
import { openTraceCaptureSettings, openTraceDetail } from '../lib/traceNavigation'
import type { TraceSessionList, TraceSessionListItem } from '../types/trace'
type TraceListState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; data: TraceSessionList }
const POLL_MS = 5_000
const PAGE_SIZE = 50
const SEARCH_DEBOUNCE_MS = 250
const MAX_MODEL_CHIPS = 2
export function TraceList() {
const t = useTranslation()
const [state, setState] = useState<TraceListState>({ status: 'loading' })
const [queryInput, setQueryInput] = useState('')
const [query, setQuery] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<TraceSessionListItem | null>(null)
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(null)
const activeRequest = useRef<AbortController | null>(null)
const host = getDesktopHost()
useEffect(() => {
const timer = window.setTimeout(() => {
setQuery(queryInput.trim())
}, SEARCH_DEBOUNCE_MS)
return () => window.clearTimeout(timer)
}, [queryInput])
const load = useCallback(async (options?: {
append?: boolean
limit?: number
offset?: number
silent?: boolean
}) => {
if (options?.silent && activeRequest.current) return
activeRequest.current?.abort()
const controller = new AbortController()
activeRequest.current = controller
const append = options?.append === true
const offset = options?.offset ?? 0
const limit = options?.limit ?? PAGE_SIZE
try {
setIsLoadingMore(append)
if (!append && !options?.silent) {
setState({ status: 'loading' })
}
const data = await tracesApi.list({ limit, offset, query }, { signal: controller.signal })
if (controller.signal.aborted || activeRequest.current !== controller) return
setState((previous) => {
if (!append || previous.status !== 'ready') {
return { status: 'ready', data }
}
return {
status: 'ready',
data: {
...data,
traces: [...previous.data.traces, ...data.traces],
},
}
})
} catch (error) {
if (controller.signal.aborted || activeRequest.current !== controller) return
setState({
status: 'error',
message: error instanceof Error ? error.message : t('trace.list.loadFailed'),
})
} finally {
if (activeRequest.current === controller) {
activeRequest.current = null
setIsLoadingMore(false)
}
}
}, [query, t])
useEffect(() => {
void load()
return () => {
activeRequest.current?.abort()
activeRequest.current = null
}
}, [load])
useEffect(() => {
if (state.status !== 'ready' || !state.data.settings.enabled) return
const timer = window.setInterval(() => {
void load({
limit: Math.max(PAGE_SIZE, state.data.traces.length),
silent: true,
})
}, POLL_MS)
return () => window.clearInterval(timer)
}, [load, state])
const summary = useMemo(() => {
if (state.status !== 'ready') return { apiCalls: 0, failedCalls: 0, models: 0 }
const modelNames = new Set<string>()
let apiCalls = 0
let failedCalls = 0
for (const item of state.data.traces) {
apiCalls += item.summary.apiCalls
failedCalls += item.summary.failedCalls
for (const model of item.summary.models) modelNames.add(model.model)
}
return { apiCalls, failedCalls, models: modelNames.size }
}, [state])
const confirmDelete = useCallback(async () => {
if (!deleteTarget) return
const currentLimit = state.status === 'ready'
? Math.max(PAGE_SIZE, state.data.traces.length)
: PAGE_SIZE
setDeletingSessionId(deleteTarget.sessionId)
try {
await tracesApi.deleteSession(deleteTarget.sessionId)
setDeleteTarget(null)
await load({ limit: currentLimit, silent: true })
} catch (error) {
setState({
status: 'error',
message: error instanceof Error ? error.message : t('trace.list.deleteFailed'),
})
} finally {
setDeletingSessionId(null)
}
}, [deleteTarget, load, state, t])
return (
<>
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<header className="shrink-0 border-b border-[var(--color-border)] px-6 py-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-[0.14em] text-[var(--color-text-tertiary)]">
<Workflow className="h-3.5 w-3.5" strokeWidth={1.6} aria-hidden="true" />
<span>{t('trace.list.eyebrow')}</span>
</div>
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
<h1
className="text-[24px] font-bold leading-tight tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
>
{t('trace.list.title')}
</h1>
{state.status === 'ready' && (
<Badge tone={state.data.settings.enabled ? 'success' : 'neutral'} size="sm" pill={false}>
{state.data.settings.enabled ? t('trace.list.collecting') : t('trace.list.paused')}
</Badge>
)}
{state.status === 'ready' && (
<span className="min-w-0 max-w-full truncate font-mono text-[12.5px] text-[var(--color-text-tertiary)]" title={state.data.storageDir}>
{state.data.storageDir}
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button size="base" variant="secondary" onClick={openTraceCaptureSettings}>
{t('trace.list.settings')}
</Button>
<Button size="base" variant="secondary" onClick={() => void load()}>
<RefreshCw className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />
{t('trace.refresh')}
</Button>
</div>
</div>
<div className="mt-4 flex flex-wrap items-baseline gap-x-6 gap-y-1.5">
<MetaChip label={t('trace.list.sessions')} value={state.status === 'ready' ? String(state.data.total) : '-'} />
<MetaChip label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone={summary.failedCalls > 0 ? 'danger' : 'default'} />
<MetaChip label={t('trace.models')} value={String(summary.models)} />
</div>
</header>
<div className="flex min-h-0 flex-1 flex-col">
<div className="shrink-0 border-b border-[var(--color-border)] px-6 py-3.5">
<SearchField
value={queryInput}
onChange={setQueryInput}
label={t('trace.list.searchPlaceholder')}
clearLabel={t('common.clearSearch')}
placeholder={t('trace.list.searchPlaceholder')}
containerClassName="max-w-xl"
/>
</div>
{state.status === 'loading' && <TraceListSkeleton label={t('common.loading')} />}
{state.status === 'error' && (
<ErrorState
className="m-6"
tone="strong"
title={t('common.error')}
detail={state.message}
onRetry={() => void load()}
retryLabel={t('common.retry')}
/>
)}
{state.status === 'ready' && (
<TraceRows
traces={state.data.traces}
total={state.data.total}
loadingMore={isLoadingMore}
deletingSessionId={deletingSessionId}
onLoadMore={() => void load({
append: true,
offset: state.data.traces.length,
silent: true,
})}
onOpenWindow={(sessionId) => {
// Matches TraceSession's own header button: outside the desktop
// shell there is no native window to ask for, so fall back to a
// browser tab rather than leaving the control dead.
if (host.trace) {
void host.trace.openWindow(sessionId)
return
}
window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer')
}}
onDelete={setDeleteTarget}
/>
)}
</div>
</div>
<ConfirmDialog
open={deleteTarget !== null}
onClose={() => {
if (!deletingSessionId) setDeleteTarget(null)
}}
onConfirm={() => void confirmDelete()}
title={t('trace.list.deleteConfirmTitle')}
body={deleteTarget
? t('trace.list.deleteConfirmBody', { title: getTraceTitle(deleteTarget, t) })
: ''}
confirmLabel={t('common.delete')}
cancelLabel={t('common.cancel')}
loading={deletingSessionId !== null}
/>
</>
)
}
function TraceRows({
loadingMore,
onLoadMore,
traces,
total,
onOpenWindow,
onDelete,
deletingSessionId,
}: {
loadingMore: boolean
onLoadMore: () => void
traces: TraceSessionListItem[]
total: number
onOpenWindow: (sessionId: string) => void
onDelete: (trace: TraceSessionListItem) => void
deletingSessionId: string | null
}) {
const t = useTranslation()
if (traces.length === 0) {
return (
<div className="flex flex-1 items-start justify-center px-6 py-10">
<EmptyState
className="w-full max-w-md"
headingLevel={2}
icon={<Workflow className="h-5 w-5" strokeWidth={2} />}
title={t('trace.list.emptyTitle')}
description={t('trace.list.emptyBody')}
/>
</div>
)
}
return (
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="divide-y divide-[var(--color-border)]" role="list">
{traces.map((trace) => (
<TraceRow
key={trace.sessionId}
trace={trace}
onOpenWindow={onOpenWindow}
onDelete={onDelete}
isDeleting={deletingSessionId === trace.sessionId}
/>
))}
</div>
<div className="flex items-center justify-between border-t border-[var(--color-border)] px-6 py-3 text-xs text-[var(--color-text-tertiary)]">
<span>{t('trace.list.loadedCount', { shown: traces.length, total })}</span>
{traces.length < total && (
<Button size="sm" variant="secondary" onClick={onLoadMore} disabled={loadingMore}>
{loadingMore ? t('common.loading') : t('trace.list.loadMore')}
</Button>
)}
</div>
</div>
)
}
function TraceRow({
trace,
onOpenWindow,
onDelete,
isDeleting,
}: {
trace: TraceSessionListItem
onOpenWindow: (sessionId: string) => void
onDelete: (trace: TraceSessionListItem) => void
isDeleting: boolean
}) {
const t = useTranslation()
const title = getTraceTitle(trace, t)
const updatedAt = trace.summary.updatedAt ?? trace.fileUpdatedAt
// The tab is titled with the session alone; the tab bar's trace glyph says
// what kind of tab it is, so a prefix here would only eat the visible width.
const open = () => openTraceDetail(trace.sessionId, title)
const failedCalls = trace.summary.failedCalls
const visibleModels = trace.summary.models.slice(0, MAX_MODEL_CHIPS)
const hiddenModels = trace.summary.models.length - visibleModels.length
const totalTokens = trace.summary.totalInputTokens + trace.summary.totalOutputTokens
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
open()
}
return (
<div
role="listitem"
aria-label={title}
className="trace-list-row-cv group flex h-14 cursor-pointer items-center gap-5 px-6 transition-colors hover:bg-[var(--color-surface-hover)]"
>
<button
type="button"
onClick={open}
onKeyDown={onKeyDown}
className="flex min-w-0 flex-1 items-center gap-5 self-stretch bg-transparent p-0 text-left"
>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2.5">
<span className="min-w-0 truncate text-[15px] font-semibold text-[var(--color-text-primary)]">{title}</span>
{visibleModels.map((model) => (
<span
key={model.model}
title={`${model.model} x${model.calls}`}
className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-brand-soft)] px-2 py-0.5 font-mono text-[11px] font-medium leading-4 text-[var(--color-on-brand-soft)]"
>
{shortModelName(model.model)}
</span>
))}
{hiddenModels > 0 && (
<span className="shrink-0 rounded-[var(--radius-sm)] bg-[var(--color-surface-container-high)] px-2 py-0.5 font-mono text-[11px] font-medium leading-4 text-[var(--color-text-tertiary)]">
+{hiddenModels}
</span>
)}
{failedCalls > 0 && (
<span title={t('trace.failedCalls')} className="flex shrink-0 items-center gap-1.5 text-[12px] font-semibold text-[var(--color-error)]">
<span className="h-[7px] w-[7px] rounded-full bg-[var(--color-error)]" aria-hidden="true" />
<span>{failedCalls}</span>
</span>
)}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 font-mono text-[12px] text-[var(--color-text-tertiary)]">
<span className="shrink-0">{trace.sessionId.slice(0, 8)}</span>
{trace.session?.projectPath && (
<>
<span aria-hidden="true">·</span>
<span className="truncate" title={trace.session.projectPath}>{trace.session.projectPath}</span>
</>
)}
<span aria-hidden="true">·</span>
<span className="shrink-0">{formatUpdatedAt(updatedAt)}</span>
</div>
</div>
<div className="grid shrink-0 grid-cols-[4.5rem_5rem_4.5rem] items-center gap-4">
<MetricCell label={t('trace.apiCalls')} value={String(trace.summary.apiCalls)} />
<MetricCell label={t('trace.modelTime')} value={formatDurationMs(trace.summary.totalDurationMs)} />
<MetricCell label={t('trace.tokens')} value={formatTokenCount(totalTokens)} />
</div>
</button>
{/* Opening the trace is what the row itself does — the row actions are
only for the two things a click cannot express. */}
<div className="flex w-[62px] shrink-0 items-center justify-end gap-1 opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100">
<IconButton
size="sm"
tone="secondary"
label={t('trace.openWindow')}
icon={<ExternalLink className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
onClick={(event) => {
event.stopPropagation()
onOpenWindow(trace.sessionId)
}}
/>
<IconButton
size="sm"
tone="secondary"
hoverTone="danger"
label={t('trace.delete')}
icon={<Trash2 className="h-3.5 w-3.5" strokeWidth={2} aria-hidden="true" />}
disabled={isDeleting}
onClick={(event) => {
event.stopPropagation()
onDelete(trace)
}}
/>
</div>
</div>
)
}
function MetricCell({ label, value }: { label: string; value: string }) {
return (
<div className="text-right">
<div className="font-mono text-[13px] font-semibold leading-5 text-[var(--color-text-primary)]">{value}</div>
<div className="truncate text-[11px] leading-4 text-[var(--color-text-tertiary)]" title={label}>{label}</div>
</div>
)
}
function MetaChip({ label, value, tone = 'default' }: { label: string; value: string; tone?: 'default' | 'danger' }) {
return (
<div className="flex items-baseline gap-1.5 text-[13px]">
<span className="text-[var(--color-text-secondary)]">{label}</span>
<span className={`font-semibold tabular-nums ${tone === 'danger' ? 'text-[var(--color-error)]' : 'text-[var(--color-text-primary)]'}`}>{value}</span>
</div>
)
}
function TraceListSkeleton({ label }: { label: string }) {
return (
<div className="min-h-0 flex-1 overflow-hidden" role="status" aria-label={label}>
<div className="divide-y divide-[var(--color-border)]" aria-hidden="true">
{Array.from({ length: 6 }, (_, index) => (
<div key={index} className="flex h-14 items-center gap-4 px-6">
<div className="min-w-0 flex-1">
<div className="h-3 w-48 max-w-full animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="mt-2 h-2.5 w-72 max-w-full animate-pulse rounded bg-[var(--color-surface-container-low)]" />
</div>
<div className="flex shrink-0 items-center gap-3">
<div className="h-3 w-10 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
<div className="h-3 w-12 animate-pulse rounded bg-[var(--color-surface-container-high)]" />
</div>
</div>
))}
</div>
</div>
)
}
function getTraceTitle(trace: TraceSessionListItem, t: ReturnType<typeof useTranslation>): string {
return trace.session?.title || t('session.untitled')
}
/** `claude-sonnet-4-5-20250929` -> `sonnet-4-5`; non-Claude ids pass through. */
function shortModelName(model: string): string {
const short = model.replace(/^claude-/i, '').replace(/-\d{8}$/, '')
return short || model
}
function formatUpdatedAt(value: string | null): string {
if (!value) return '-'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return '-'
return date.toLocaleString()
}
File diff suppressed because it is too large Load Diff
-666
View File
@@ -1,666 +0,0 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
AlertTriangle,
ArrowLeft,
CheckCircle2,
Copy,
ExternalLink,
RadioTower,
RefreshCw,
} from 'lucide-react'
import { sessionsApi } from '../api/sessions'
import { tracesApi } from '../api/traces'
import { useSessionStore } from '../stores/sessionStore'
import { useTranslation } from '../i18n'
import type { MessageEntry } from '../types/session'
import type { TraceSession as TraceSessionData } from '../types/trace'
import { getDesktopHost } from '../lib/desktopHost'
import { buildTraceWindowUrl } from '../lib/traceLaunch'
import { formatClockTime, formatDurationMs, formatTokenCount } from '../lib/trace/formatters'
import { Button } from '@/components/ui/Button'
import { StatusDot } from '@/components/ui/Badge'
import { CopyButton } from '@/components/ui/CopyButton'
import { EmptyState } from '@/components/ui/EmptyState'
import { IconButton } from '@/components/ui/IconButton'
import { TraceSplitLayout } from '../components/trace/TraceSplitLayout'
import { TraceTree } from '../components/trace/TraceTree'
import { TraceDetail } from '../components/trace/TraceDetail'
import { TraceSectionStateProvider } from '../components/trace/detail/Section'
import { LiveBadge, MetaChip, StatusPill, TypeIcon, spanDisplayTitle } from '../components/trace/TraceBadges'
import {
buildTraceViewModel,
type TraceSpan,
type TraceViewModel,
} from '../lib/traceViewModel'
type LoadState =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; trace: TraceSessionData; messages: MessageEntry[] }
type TraceTranslator = ReturnType<typeof useTranslation>
const TRACE_POLL_INTERVAL_MS = 1500
export function TraceSession({
sessionId,
standalone = false,
onBack,
pollIntervalMs = TRACE_POLL_INTERVAL_MS,
}: {
sessionId: string
standalone?: boolean
/**
* Return to the trace list. Omitted in the standalone window, which has no
* list to go back to.
*/
onBack?: () => void
pollIntervalMs?: number
}) {
const t = useTranslation()
const sessionTitle = useSessionStore((s) => s.sessions.find((session) => session.id === sessionId)?.title)
const [state, setState] = useState<LoadState>({ status: 'loading' })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [refreshNonce, setRefreshNonce] = useState(0)
const [tracePage, setTracePage] = useState<{ offset?: number; revisionToken?: string; scanCursor?: string }>({})
const [lastLoadedAt, setLastLoadedAt] = useState<string | null>(null)
const [refreshing, setRefreshing] = useState(false)
const [clockNowMs, setClockNowMs] = useState(() => Date.now())
const snapshotSignatureRef = useRef<string | null>(null)
const lastSpanIdRef = useRef<string | null>(null)
useEffect(() => {
let cancelled = false
const controller = new AbortController()
let loadedMessages: MessageEntry[] | undefined
let loadedMessageSignature: string | null | undefined
let loadInFlight = false
let revisionPollingAvailable = true
let currentRevision: number | undefined
let currentRevisionToken: string | undefined
let revisionRequestGeneration = 0
snapshotSignatureRef.current = null
const load = async (silent: boolean) => {
if (silent && loadInFlight) return
loadInFlight = true
if (!silent) setState({ status: 'loading' })
try {
let pendingRevision: number | undefined
let pendingRevisionKey: string | undefined
if (silent && revisionPollingAvailable) {
try {
const requestGeneration = ++revisionRequestGeneration
const revision = await tracesApi.getRevision(
sessionId,
currentRevision,
currentRevisionToken,
{ signal: controller.signal },
)
if (cancelled || requestGeneration !== revisionRequestGeneration) return
if (!revision.changed) {
currentRevision = revision.revision
currentRevisionToken = revision.revisionToken
return
}
pendingRevision = revision.revision
pendingRevisionKey = traceRevisionKey(revision)
} catch {
// Older sidecars keep the previous full-snapshot polling behavior.
revisionPollingAvailable = false
}
}
if (silent) setRefreshing(true)
const trace = await sessionsApi.getTrace(sessionId, { signal: controller.signal }, tracePage)
if (!isTraceSessionData(trace)) {
throw new Error(t('trace.snapshotEmpty'))
}
if (cancelled) return
if (!silent && revisionPollingAvailable) {
const requestGeneration = ++revisionRequestGeneration
void tracesApi.getRevision(sessionId, undefined, undefined, { signal: controller.signal }).then((revision) => {
if (cancelled || requestGeneration !== revisionRequestGeneration) return
currentRevision = revision.revision
currentRevisionToken = revision.revisionToken
}).catch(() => {
if (cancelled || requestGeneration !== revisionRequestGeneration) return
revisionPollingAvailable = false
})
} else if (pendingRevision !== undefined) {
currentRevision = pendingRevision
currentRevisionToken = pendingRevisionKey?.startsWith('token:')
? pendingRevisionKey.slice('token:'.length)
: undefined
}
const signature = traceSnapshotSignature(trace)
if (silent && snapshotSignatureRef.current === signature) return
// Trace calls/events often advance without any transcript mutation.
// Reuse the transcript until its independent revision changes.
if (!loadedMessages || trace.messageSignature == null || trace.messageSignature !== loadedMessageSignature) {
const messageResponse = await sessionsApi.getHistoryPage(sessionId, {}, { signal: controller.signal })
.catch(() => null)
if (cancelled) return
if (messageResponse) {
// A tail page cannot assign historical calls to their original turns.
// Keep those calls under session activity instead of inventing an association.
loadedMessages = messageResponse.page?.historyComplete === false ? [] : messageResponse.messages
loadedMessageSignature = trace.messageSignature
}
}
if (cancelled) return
snapshotSignatureRef.current = loadedMessages ? signature : null
setState({ status: 'ready', trace, messages: loadedMessages ?? [] })
setClockNowMs(Date.now())
setLastLoadedAt(new Date().toISOString())
} catch (error) {
if (cancelled) return
if (!silent) {
setState({ status: 'error', message: error instanceof Error ? error.message : String(error) })
}
} finally {
loadInFlight = false
if (!cancelled) setRefreshing(false)
}
}
setSelectedId(null)
lastSpanIdRef.current = null
void load(false)
const interval = window.setInterval(() => {
// Keep a historical page stable. Returning to the first page resumes live polling.
if (!tracePage.offset && !tracePage.scanCursor) void load(true)
}, pollIntervalMs)
return () => {
cancelled = true
controller.abort()
window.clearInterval(interval)
}
}, [sessionId, refreshNonce, pollIntervalMs, t, tracePage])
const refresh = () => {
setTracePage({})
setRefreshNonce((value) => value + 1)
}
const openWindow = () => {
const host = getDesktopHost()
if (host.trace) {
void host.trace.openWindow(sessionId)
return
}
window.open(buildTraceWindowUrl(sessionId), '_blank', 'noopener,noreferrer')
}
const readyState = state.status === 'ready' ? state : null
const viewModel = useMemo(
() => readyState
? buildTraceViewModel(readyState.trace, readyState.messages, { now: new Date(clockNowMs).toISOString() })
: null,
[readyState, clockNowMs],
)
useEffect(() => {
if (!viewModel) return
if (viewModel.diagnosis.pendingModelCalls === 0 && viewModel.diagnosis.pendingToolCalls === 0) return
const timer = window.setInterval(() => setClockNowMs(Date.now()), 1000)
return () => window.clearInterval(timer)
}, [viewModel?.diagnosis.pendingModelCalls, viewModel?.diagnosis.pendingToolCalls])
useEffect(() => {
if (!viewModel) return
const lastSpanId = viewModel.orderedSpanIds.at(-1) ?? null
setSelectedId((current) => {
// Follow the live tail only when the user was already reading the tail.
if (current && current === lastSpanIdRef.current && lastSpanId && current !== lastSpanId) {
lastSpanIdRef.current = lastSpanId
return lastSpanId
}
lastSpanIdRef.current = lastSpanId
if (current && viewModel.spansById.has(current)) return current
return viewModel.rootId
})
}, [viewModel])
if (state.status === 'loading') {
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<TraceHeader
sessionId={sessionId}
title={sessionTitle ?? t('session.untitled')}
standalone={standalone}
onBack={onBack}
onOpenWindow={openWindow}
onRefresh={refresh}
refreshing={refreshing}
updatedAt={lastLoadedAt}
/>
<TraceSkeleton />
</div>
)
}
if (state.status === 'error') {
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)]">
<TraceHeader
sessionId={sessionId}
title={sessionTitle ?? t('session.untitled')}
standalone={standalone}
onBack={onBack}
onOpenWindow={openWindow}
onRefresh={refresh}
refreshing={refreshing}
updatedAt={lastLoadedAt}
/>
<div className="flex flex-1 items-center justify-center p-8">
<div className="max-w-md rounded-[var(--radius-xl)] border border-[var(--color-error)] bg-[var(--color-error-container)] px-6 py-5">
<div className="flex items-center gap-2 text-sm font-semibold text-[var(--color-on-error-container)]">
<AlertTriangle size={14} strokeWidth={2} />
{t('trace.loadFailed')}
</div>
<p className="mt-2 text-sm text-[var(--color-on-error-container)]">{state.message}</p>
<Button
variant="secondary"
size="base"
className="mt-4"
onClick={refresh}
icon={<RefreshCw size={14} strokeWidth={2} />}
>
{t('common.retry')}
</Button>
</div>
</div>
</div>
)
}
const { trace, messages } = state
const resolvedTitle = sessionTitle ?? trace.session?.title ?? t('session.untitled')
if (!viewModel) {
return <TraceEmpty />
}
const hasTraceContent = trace.calls.length > 0 || (trace.events?.length ?? 0) > 0 || messages.length > 0
const selectedSpan = selectedId ? viewModel.spansById.get(selectedId) : undefined
const activeSpan = selectedSpan ?? viewModel.spansById.get(viewModel.rootId) ?? viewModel.spans[0] ?? null
// An open record follows its own content, not the session revision. A sibling
// span arriving must not clear and refetch the record the reader is looking at.
const detailRevisionKey = spanContentKey(activeSpan)
// The overview reads the opening call, so it must use that call's content key.
// A different key misses the detail cache and refetches the same body.
const overviewRevisionKey = spanContentKey(
viewModel.spans.find((span) => span.kind === 'llm') ?? null,
)
return (
<div className="flex min-h-0 flex-1 flex-col bg-[var(--color-surface)] text-[var(--color-text-primary)]">
<TraceHeader
sessionId={sessionId}
title={resolvedTitle}
trace={trace}
viewModel={viewModel}
standalone={standalone}
onBack={onBack}
onOpenWindow={openWindow}
onRefresh={refresh}
refreshing={refreshing}
updatedAt={lastLoadedAt}
/>
{trace.window && (
<div className="flex flex-wrap items-center gap-2 border-b border-[var(--color-border)] px-4 py-2 text-xs" role="status">
<span>{t('trace.windowNotice')}</span>
<span>{trace.window.offset + 1}–{trace.window.offset + Math.max(trace.calls.length, trace.events?.length ?? 0)} / {Math.max(trace.window.totalCalls, trace.window.totalEvents)}</span>
{(trace.window.state === 'limited' || trace.window.oversizedRecords > 0) && <span>{t('trace.windowLimited')}</span>}
<Button size="sm" variant="ghost" disabled={!tracePage.offset && !tracePage.scanCursor} onClick={() => setTracePage({})}>{t('trace.windowFirst')}</Button>
<Button size="sm" variant="ghost" disabled={!tracePage.offset} onClick={() => setTracePage({ ...tracePage, offset: Math.max(0, (tracePage.offset ?? 0) - trace.window!.limit) })}>{t('trace.windowPrevious')}</Button>
<Button size="sm" variant="ghost" disabled={!trace.window.hasMore} onClick={() => setTracePage({ ...tracePage, offset: trace.window!.offset + trace.window!.limit, revisionToken: trace.window!.revisionToken })}>{t('trace.windowNext')}</Button>
{trace.window.nextScanCursor && <Button size="sm" variant="ghost" onClick={() => setTracePage({ scanCursor: trace.window!.nextScanCursor })}>{t('trace.windowScanNext')}</Button>}
</div>
)}
<DiagnosisBanner viewModel={viewModel} onSelect={setSelectedId} />
{hasTraceContent && activeSpan ? (
<div className="flex min-h-0 flex-1 flex-col">
<TraceSectionStateProvider scopeId={sessionId}>
<TraceSplitLayout
tree={
<TraceTree
viewModel={viewModel}
selectedId={activeSpan.id}
onSelect={setSelectedId}
/>
}
detail={
<TraceDetail
span={activeSpan}
viewModel={viewModel}
sessionId={sessionId}
revisionKey={detailRevisionKey}
overviewRevisionKey={overviewRevisionKey}
onSelect={setSelectedId}
/>
}
/>
</TraceSectionStateProvider>
</div>
) : (
<TraceEmpty />
)}
</div>
)
}
function spanContentKey(span: TraceSpan | null): string | undefined {
if (!span) return undefined
const call = span.call
if (call) {
return [
call.id,
call.status ?? span.status,
call.completedAt ?? '',
call.durationMs ?? '',
call.request.body.sha256,
call.response?.body.sha256 ?? '',
call.error?.name ?? '',
call.error?.message ?? '',
].join(':')
}
return [
span.id,
span.status,
span.completedAt ?? '',
span.durationMs ?? '',
].join(':')
}
function traceRevisionKey(revision: {
revision: number
revisionToken?: string
}): string {
return revision.revisionToken
? `token:${revision.revisionToken}`
: `legacy:${revision.revision}`
}
function TraceHeader({
sessionId,
title,
trace,
viewModel,
standalone,
onBack,
onOpenWindow,
onRefresh,
refreshing = false,
updatedAt,
}: {
sessionId: string
title: string
trace?: TraceSessionData
viewModel?: TraceViewModel | null
standalone?: boolean
onBack?: () => void
onOpenWindow?: () => void
onRefresh?: () => void
refreshing?: boolean
updatedAt?: string | null
}) {
const t = useTranslation()
const summary = trace?.summary
const diagnosisStatus = viewModel?.diagnosis.status
return (
<header
className="flex shrink-0 items-center justify-between gap-3.5 border-b border-[var(--color-border)] px-6 py-4"
data-testid="trace-header"
>
<div className="flex min-w-0 items-center gap-3.5">
{/*
A trace tab is a drill-down, so it owes the reader a way back up. The
hairline separates this navigation affordance from the identity block
beside it, the way a breadcrumb separates from a page title.
*/}
{onBack ? (
<div className="flex shrink-0 items-center gap-3.5">
<Button
variant="ghost"
size="base"
onClick={onBack}
icon={<ArrowLeft size={15} strokeWidth={2} aria-hidden="true" />}
data-testid="trace-back"
>
{t('trace.backToList')}
</Button>
<span className="h-6 w-px bg-[var(--color-border)]" aria-hidden="true" />
</div>
) : null}
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--radius-md)] bg-[var(--color-brand-soft)] text-[var(--color-on-brand-soft)]">
<RadioTower size={17} strokeWidth={1.8} aria-hidden="true" />
</span>
<div className="min-w-0">
<div className="flex min-w-0 items-center gap-2.5">
<h1
className="min-w-0 truncate text-[17px] font-bold tracking-tight text-[var(--color-text-primary)]"
style={{ fontFamily: 'var(--font-headline)' }}
title={title}
>
{title}
</h1>
<LiveBadge />
{diagnosisStatus ? <DiagnosisDot status={diagnosisStatus} /> : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 font-mono text-[12.5px] text-[var(--color-text-tertiary)]">
<span className="max-w-[280px] truncate">{sessionId}</span>
{updatedAt ? (
<>
<span aria-hidden="true">·</span>
<span className="shrink-0">{t('trace.updatedAt')} {formatClockTime(updatedAt)}</span>
</>
) : null}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-3.5">
{summary ? (
<div className="hidden items-center gap-3.5 md:flex">
<MetaChip label={t('trace.apiCalls')} value={String(summary.apiCalls)} />
{summary.failedCalls > 0 ? (
<MetaChip label={t('trace.failedCalls')} value={String(summary.failedCalls)} tone="danger" />
) : null}
<MetaChip label={t('trace.modelTime')} value={formatDurationMs(summary.totalDurationMs)} />
<MetaChip
label={t('trace.tokens')}
value={formatTokenCount(summary.totalInputTokens + summary.totalOutputTokens)}
/>
{summary.models.length > 0 ? (
<MetaChip
label={t('trace.models')}
value={summary.models.map((model) => `${model.model} x${model.calls}`).join(', ')}
/>
) : null}
</div>
) : null}
<div className="flex items-center gap-1.5">
<CopyButton
text={sessionId}
label={t('trace.copySessionId')}
copiedLabel={t('common.copied')}
displayLabel={<Copy size={16} strokeWidth={2} />}
displayCopiedLabel={<CheckCircle2 size={16} strokeWidth={2} />}
// Mirrors IconButton size="md" tone="secondary" bordered, so this
// sits flush with the two IconButtons beside it. CopyButton owns its
// own copied/reset state, so it cannot be swapped for IconButton.
className="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--radius-md)] border border-[var(--color-border)] text-[var(--color-text-secondary)] transition-colors duration-150 hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-text-primary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-border-focus)]"
/>
<IconButton
size="md"
tone="secondary"
bordered
label={t('trace.refresh')}
onClick={onRefresh}
icon={<RefreshCw size={16} strokeWidth={2} className={refreshing ? 'animate-spin' : ''} />}
/>
{!standalone ? (
<IconButton
size="md"
tone="secondary"
bordered
label={t('trace.openWindow')}
onClick={onOpenWindow}
icon={<ExternalLink size={16} strokeWidth={2} />}
/>
) : null}
</div>
</div>
</header>
)
}
function DiagnosisDot({ status }: { status: TraceViewModel['diagnosis']['status'] }) {
const tone = status === 'blocked' ? 'danger' : status === 'attention' ? 'warning' : 'success'
return <StatusDot tone={tone} size="md" pulse />
}
function DiagnosisBanner({
viewModel,
onSelect,
}: {
viewModel: TraceViewModel
onSelect: (spanId: string) => void
}) {
const t = useTranslation()
const diagnosis = viewModel.diagnosis
if (diagnosis.status !== 'attention' && diagnosis.status !== 'blocked') return null
const focusSpan = diagnosis.focusSpanId ? viewModel.spansById.get(diagnosis.focusSpanId) : undefined
const evidenceSpans = diagnosis.evidenceSpanIds
.map((spanId) => viewModel.spansById.get(spanId))
.filter((span): span is TraceSpan => !!span)
.slice(0, 3)
// Warning-bar recipe from the redesign spec: `-container` fill, matching
// `on-*-container` ink, same-tone hairline. No alpha modifiers — Safari 15
// drops the color function they compile to.
const toneClass = diagnosis.status === 'blocked'
? 'border-[var(--color-error)] bg-[var(--color-error-container)] text-[var(--color-on-error-container)]'
: 'border-[var(--color-warning)] bg-[var(--color-warning-container)] text-[var(--color-on-warning-container)]'
return (
<section className={`flex shrink-0 items-center gap-2.5 border-b px-6 py-2 ${toneClass}`} data-testid="trace-diagnosis">
<StatusPill status={diagnosis.status === 'blocked' ? 'error' : 'pending'} />
<span className="min-w-0 truncate text-[12.5px] font-semibold">
{diagnosisReasonLabel(diagnosis.reason, t)}
</span>
{focusSpan ? (
<Button
variant="secondary"
size="xs"
className="shrink-0"
onClick={() => onSelect(focusSpan.id)}
>
{t('trace.focus')}
</Button>
) : null}
<div className="ml-auto flex min-w-0 items-center justify-end gap-1.5 overflow-hidden">
{evidenceSpans.map((span) => (
<Button
key={span.id}
variant="secondary"
size="xs"
className="max-w-[200px]"
onClick={() => onSelect(span.id)}
icon={<TypeIcon span={span} size={13} />}
>
<span className="truncate">{spanDisplayTitle(span, t)}</span>
</Button>
))}
</div>
</section>
)
}
function diagnosisReasonLabel(reason: TraceViewModel['diagnosis']['reason'], t: TraceTranslator): string {
switch (reason) {
case 'model_error': return t('trace.diagnosis.modelError')
case 'tool_error': return t('trace.diagnosis.toolError')
case 'event_error': return t('trace.diagnosis.eventError')
case 'pending_model': return t('trace.diagnosis.pendingModel')
case 'pending_tool': return t('trace.diagnosis.pendingTool')
case 'waiting_for_agent': return t('trace.diagnosis.waitingForAgent')
case 'empty': return t('trace.diagnosis.empty')
default: return t('trace.diagnosis.healthy')
}
}
function TraceSkeleton() {
return (
<div className="flex min-h-0 flex-1 flex-col lg:flex-row" data-testid="trace-skeleton">
<div className="shrink-0 border-b border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4 lg:w-[400px] lg:border-b-0 lg:border-r">
<div className="h-8 animate-pulse rounded-[var(--radius-md)] bg-[var(--color-surface-container)]" />
<div className="mt-3 space-y-1.5">
{Array.from({ length: 10 }).map((_, index) => (
<div key={index} className="h-[34px] animate-pulse rounded-[var(--radius-sm)] bg-[var(--color-surface-container)]" />
))}
</div>
</div>
<div className="min-w-0 flex-1 p-6">
<div className="h-5 w-64 animate-pulse rounded bg-[var(--color-surface-container)]" />
<div className="mt-2 h-3 w-96 animate-pulse rounded bg-[var(--color-surface-container)]" />
<div className="mt-5 space-y-3">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="h-24 animate-pulse rounded-[var(--radius-md)] bg-[var(--color-surface-container)]" />
))}
</div>
</div>
</div>
)
}
function TraceEmpty() {
const t = useTranslation()
return (
<div className="flex flex-1 items-center justify-center p-8">
<EmptyState
className="max-w-sm"
headingLevel={2}
icon={<RadioTower size={20} strokeWidth={2} />}
title={t('trace.emptyTitle')}
description={t('trace.emptyBody')}
/>
</div>
)
}
function traceSnapshotSignature(trace: TraceSessionData): string {
return JSON.stringify({
summary: trace.summary,
messageSignature: trace.messageSignature ?? null,
calls: trace.calls.map((call) => ({
id: call.id,
status: call.status,
completedAt: call.completedAt,
durationMs: call.durationMs,
usage: call.usage,
responseStatus: call.response?.status,
requestSha256: call.request.body.sha256,
responseSha256: call.response?.body.sha256,
error: call.error ? { name: call.error.name, message: call.error.message, code: call.error.code } : null,
})),
events: (trace.events ?? []).map((event) => ({
id: event.id,
timestamp: event.timestamp,
phase: event.phase,
severity: event.severity,
callId: event.callId,
message: event.message,
})),
})
}
function isTraceSessionData(value: unknown): value is TraceSessionData {
return !!value &&
typeof value === 'object' &&
'sessionId' in value &&
'summary' in value &&
Array.isArray((value as { calls?: unknown }).calls)
}
+1 -3
View File
@@ -502,9 +502,7 @@ function removeIdsFromSet(selected: Set<string>, ids: string[]): Set<string> {
}
function getOpenSessionIds(): Set<string> {
return new Set(useTabStore.getState().tabs.flatMap((tab) => tab.type === 'session'
? [tab.sessionId]
: tab.type === 'trace' && tab.traceSessionId ? [tab.traceSessionId] : []))
return new Set(useTabStore.getState().tabs.flatMap((tab) => tab.type === 'session' ? [tab.sessionId] : []))
}
function getProjectHistorySessionIds(projectHistory: Record<string, ProjectHistoryState>): Set<string> {

Some files were not shown because too many files have changed in this diff Show More