diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index a627720294..fb369da242 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode } from 'react' import { - extractMarkdownPlainText, IconChevronRightOutline14, IconSettingsOutline16, IconSparkle16, @@ -20,7 +19,7 @@ import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, } from './trajectory-record.ts' import { formatElapsedSeconds } from './trajectory-record.ts' -import type { TrajectoryTurnModel } from './layout.ts' +import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' import css from './TrajectoryTable.module.css' const KIND_LABEL: Record = { @@ -801,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' + if (cell.text !== '') return cell.text const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail : cell.kind === 'message' ? cell.outputDetail ?? cell.thinkingDetail : undefined - if (!markdown) return cell.text - return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim() + return markdown === undefined ? '' : trajectoryPreviewText(markdown) } function toolCallTextParts( @@ -1502,7 +1501,7 @@ export function TrajectoryTable({ const [selectedIndex, setSelectedIndex] = useState(null) const [selectedRequest, setSelectedRequest] = useState(null) const [activeTab, setActiveTab] = useState('overview') - const [thinkingExpanded, setThinkingExpanded] = useState(true) + const [thinkingExpanded, setThinkingExpanded] = useState(false) const [detailsWidth, setDetailsWidth] = useState(null) const [toolRequestOffset, setToolRequestOffset] = useState(null) const detailsResizeDrag = useRef(null) diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 6e69d7ae31..e180487630 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -66,6 +66,9 @@ interface TurnBucket { groups: LaidGroup[] } +const PREVIEW_SOURCE_CHARACTERS = 2_048 +const PREVIEW_OUTPUT_CHARACTERS = 512 + type InputNode = Extract< ConversationSnapshot['nodes'][number], { kind: 'user' | 'steering' | 'context' } @@ -126,6 +129,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches, } = input const resultByCall = indexResults(nodes) + const emittedCallIds = indexAssistantCallIds(nodes) const callStartById = new Map() for (const result of resultByCall.values()) { const startedAt = finiteTime(result.callTime) @@ -353,7 +357,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T continue } if (node.kind === 'tool-result') { - if (!callEmittedInAssistant(nodes, node.callId)) { + if (!emittedCallIds.has(node.callId)) { const toolName = node.call?.name const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), @@ -764,12 +768,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map { + const ids = new Set() for (const node of nodes) { if (node.kind !== 'assistant') continue - if (node.blocks.some(b => b.kind === 'tool-call' && b.callId === callId)) return true + for (const block of node.blocks) { + if (block.kind === 'tool-call') ids.add(block.callId) + } } - return false + return ids } function collectCallIds( @@ -849,7 +856,7 @@ function expandSubCalls( } function summarizeCall(name: string, argsRaw: string): string { - const args = argsRaw.replace(/\s+/g, ' ').trim() + const args = trajectoryPreviewText(argsRaw) if (args === '') return name return `${name} · ${args}` } @@ -907,5 +914,26 @@ function summarizeContent(content: readonly { type: string; text?: string }[]): } function summarizeText(text: string): string { - return text.replace(/\s+/g, ' ').trim() + return trajectoryPreviewText(text) +} + +/** + * Build a bounded one-line ledger preview without parsing the complete Markdown document. + * Full source remains on the cell for the inspector. + * @param text - Untrusted message, reasoning, payload, or result text. + * @returns A compact preview capped independently from the retained source. + */ +export function trajectoryPreviewText(text: string): string { + const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS) + const compact = source + .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/(^|\s)(?:#{1,6}|[-+*>])\s+/g, '$1') + .replace(/[*_~`]+/g, '') + .replace(/\s+/g, ' ') + .trim() + const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd() + return source.length < text.length || preview.length < compact.length + ? `${preview}…` + : preview } diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index c83cffb846..b90e6bf38a 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -176,6 +176,25 @@ describe('deriveTrajectoryLayout', () => { }) }) + it('bounds a long Markdown-like thinking preview while retaining its full detail', () => { + const thinking = `# Investigation\n\n**finding** ${'- repeated detail '.repeat(1_000)}` + const nodes = [{ + kind: 'assistant', seq: 1, time: 5_000, turn: 1, step: 0, + blocks: [{ kind: 'reasoning', text: thinking }], + }] as unknown as ConversationSnapshot['nodes'] + + const turns = deriveTrajectoryLayout({ + codeDispatches: new Map(), nodes, partial: null, runningCalls: [], + }) + const message = turns[0]?.groups.flatMap(group => group.cells) + .find(cell => cell.kind === 'message') + + expect(message?.text.startsWith('Investigation finding')).toBe(true) + expect(message?.text.endsWith('…')).toBe(true) + expect(message?.text.length).toBeLessThanOrEqual(513) + expect(message?.thinkingDetail).toBe(thinking) + }) + it('advances the duration cursor over context nodes', () => { const nodes = [ { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null }, diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 5b4af3e0de..39c4cf8084 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -83,6 +83,31 @@ describe('TrajectoryTable', () => { expect(screen.getByText('15 tok')).toBeTruthy() }) + it('keeps long thinking collapsed until the user asks to render it', () => { + const thinking = 'private chain '.repeat(1_000) + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'message', + text: 'private chain…', + thinkingDetail: thinking, + timeSeconds: 1, + }], + }], + }] + render() + + fireEvent.click(screen.getByRole('row', { name: /ASSISTANT/ })) + const toggle = screen.getByRole('button', { name: 'Thinking ...' }) + expect(screen.queryByText(thinking)).toBeNull() + + fireEvent.click(toggle) + expect(toggle.parentElement?.textContent?.length).toBeGreaterThan(thinking.length) + }) + it('keeps raw HTML tags in a Markdown-derived context preview', () => { const html = [ '',