소스 검색

fix(ui-trajectory): bound expensive record previews

_Kerman 1 개월 전
부모
커밋
7ec01dad08

+ 4 - 5
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<TrajectoryCellKind, string> = {
@@ -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<number | null>(null)
   const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
   const [activeTab, setActiveTab] = useState<DetailTab>('overview')
-  const [thinkingExpanded, setThinkingExpanded] = useState(true)
+  const [thinkingExpanded, setThinkingExpanded] = useState(false)
   const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
   const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)

+ 34 - 6
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<string, number>()
   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<string, ToolRes
   return map
 }
 
-function callEmittedInAssistant(nodes: ConversationSnapshot['nodes'], callId: string): boolean {
+function indexAssistantCallIds(nodes: ConversationSnapshot['nodes']): ReadonlySet<string> {
+  const ids = new Set<string>()
   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
 }

+ 19 - 0
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 },

+ 25 - 0
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(<TrajectoryTable turns={turns} {...FOLD_PROPS} />)
+
+    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 = [
       '<background-task-complete id="trajectory-ui-watch">',