fix(ui-trajectory): bound expensive record previews
This commit is contained in:
@@ -3,7 +3,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { CSSProperties, ReactNode } from 'react'
|
import type { CSSProperties, ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
extractMarkdownPlainText,
|
|
||||||
IconChevronRightOutline14,
|
IconChevronRightOutline14,
|
||||||
IconSettingsOutline16,
|
IconSettingsOutline16,
|
||||||
IconSparkle16,
|
IconSparkle16,
|
||||||
@@ -20,7 +19,7 @@ import type {
|
|||||||
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
|
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
|
||||||
} from './trajectory-record.ts'
|
} from './trajectory-record.ts'
|
||||||
import { formatElapsedSeconds } 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'
|
import css from './TrajectoryTable.module.css'
|
||||||
|
|
||||||
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
|
||||||
@@ -801,13 +800,13 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
|||||||
|
|
||||||
function recordDisplayText(cell: TrajectoryCellProps): string {
|
function recordDisplayText(cell: TrajectoryCellProps): string {
|
||||||
if (isToolCallOnly(cell)) return ''
|
if (isToolCallOnly(cell)) return ''
|
||||||
|
if (cell.text !== '') return cell.text
|
||||||
const markdown = cell.kind === 'user' || cell.kind === 'context'
|
const markdown = cell.kind === 'user' || cell.kind === 'context'
|
||||||
? cell.inputDetail
|
? cell.inputDetail
|
||||||
: cell.kind === 'message'
|
: cell.kind === 'message'
|
||||||
? cell.outputDetail ?? cell.thinkingDetail
|
? cell.outputDetail ?? cell.thinkingDetail
|
||||||
: undefined
|
: undefined
|
||||||
if (!markdown) return cell.text
|
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
|
||||||
return extractMarkdownPlainText(markdown).replace(/\s+/g, ' ').trim()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toolCallTextParts(
|
function toolCallTextParts(
|
||||||
@@ -1502,7 +1501,7 @@ export function TrajectoryTable({
|
|||||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
|
||||||
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
|
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
|
||||||
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
|
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
|
||||||
const [thinkingExpanded, setThinkingExpanded] = useState(true)
|
const [thinkingExpanded, setThinkingExpanded] = useState(false)
|
||||||
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
|
const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
|
||||||
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
|
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
|
||||||
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ interface TurnBucket {
|
|||||||
groups: LaidGroup[]
|
groups: LaidGroup[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PREVIEW_SOURCE_CHARACTERS = 2_048
|
||||||
|
const PREVIEW_OUTPUT_CHARACTERS = 512
|
||||||
|
|
||||||
type InputNode = Extract<
|
type InputNode = Extract<
|
||||||
ConversationSnapshot['nodes'][number],
|
ConversationSnapshot['nodes'][number],
|
||||||
{ kind: 'user' | 'steering' | 'context' }
|
{ kind: 'user' | 'steering' | 'context' }
|
||||||
@@ -126,6 +129,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
nodes, partial, runningCalls, requests = [], callSchemas, codeDispatches,
|
||||||
} = input
|
} = input
|
||||||
const resultByCall = indexResults(nodes)
|
const resultByCall = indexResults(nodes)
|
||||||
|
const emittedCallIds = indexAssistantCallIds(nodes)
|
||||||
const callStartById = new Map<string, number>()
|
const callStartById = new Map<string, number>()
|
||||||
for (const result of resultByCall.values()) {
|
for (const result of resultByCall.values()) {
|
||||||
const startedAt = finiteTime(result.callTime)
|
const startedAt = finiteTime(result.callTime)
|
||||||
@@ -353,7 +357,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (node.kind === 'tool-result') {
|
if (node.kind === 'tool-result') {
|
||||||
if (!callEmittedInAssistant(nodes, node.callId)) {
|
if (!emittedCallIds.has(node.callId)) {
|
||||||
const toolName = node.call?.name
|
const toolName = node.call?.name
|
||||||
const laidList: LaidCell[] = [{
|
const laidList: LaidCell[] = [{
|
||||||
absTime: finiteTime(node.callTime ?? node.time),
|
absTime: finiteTime(node.callTime ?? node.time),
|
||||||
@@ -764,12 +768,15 @@ function indexResults(nodes: ConversationSnapshot['nodes']): Map<string, ToolRes
|
|||||||
return map
|
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) {
|
for (const node of nodes) {
|
||||||
if (node.kind !== 'assistant') continue
|
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(
|
function collectCallIds(
|
||||||
@@ -849,7 +856,7 @@ function expandSubCalls(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarizeCall(name: string, argsRaw: string): string {
|
function summarizeCall(name: string, argsRaw: string): string {
|
||||||
const args = argsRaw.replace(/\s+/g, ' ').trim()
|
const args = trajectoryPreviewText(argsRaw)
|
||||||
if (args === '') return name
|
if (args === '') return name
|
||||||
return `${name} · ${args}`
|
return `${name} · ${args}`
|
||||||
}
|
}
|
||||||
@@ -907,5 +914,26 @@ function summarizeContent(content: readonly { type: string; text?: string }[]):
|
|||||||
}
|
}
|
||||||
|
|
||||||
function summarizeText(text: string): 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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', () => {
|
it('advances the duration cursor over context nodes', () => {
|
||||||
const nodes = [
|
const nodes = [
|
||||||
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
{ kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'hi' }], source: null },
|
||||||
|
|||||||
@@ -83,6 +83,31 @@ describe('TrajectoryTable', () => {
|
|||||||
expect(screen.getByText('15 tok')).toBeTruthy()
|
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', () => {
|
it('keeps raw HTML tags in a Markdown-derived context preview', () => {
|
||||||
const html = [
|
const html = [
|
||||||
'<background-task-complete id="trajectory-ui-watch">',
|
'<background-task-complete id="trajectory-ui-watch">',
|
||||||
|
|||||||
Reference in New Issue
Block a user