perf(ui-trajectory): defer trajectory text processing

This commit is contained in:
imccyu
2026-08-11 03:29:04 +08:00
parent 58ea69f64f
commit e27dba8457
12 changed files with 415 additions and 168 deletions
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: 1d5fd20bfa8c3b370f736937d54a668ca7f19ca3
2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e
2026-08-09-client-conversation-node-assembly.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526
2026-08-09-client-conversation-node-assembly.zh.md: 2ddb14c35b3ac5aeba5b00e7d56b3a4e69a97adb
@@ -330,6 +330,8 @@ The concrete Tool renderer remains governed by the [`ui-tool ownership decision`
Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts.
Trajectory stage/layout processing retains raw summary sources and structural data without parsing Markdown. A stable Record presentation in the Table memoizes each one-line summary by content and shares the result across body text, title, and aria-label; Detail renders only the selected record. Timeline timing labels invoke their formatters only after the delayed Tooltip opens. Search owns an independent per-view `TrajectorySearchIndex` keyed by stable Record identity with each source signature and normalized text. The initial window is indexed immediately, and a three-second throttle commits later new or changed Records in batches. Queries read only the latest committed index version, so a prepended page enters results atomically with the next batch; neither prepend nor append reparses unchanged historical Markdown. Display caching and search indexing do not share lifecycles.
## Runtime and render path
```text
@@ -330,6 +330,8 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat
Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slicetarget 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
Trajectory 的 stage/layout 只保留原始摘要来源和结构数据,不解析 Markdown。Table 的稳定 Record presentation 按内容 memo 单行摘要,并把同一结果用于正文、title 与 aria-labelDetail 只渲染当前选中记录。Timeline 的时序标签只在延迟 Tooltip 实际打开后执行格式化。搜索拥有独立的 per-view `TrajectorySearchIndex`,按稳定 Record identity 保存来源签名和标准化文本;初始窗口立即建立索引,后续新增或变化的 Record 由三秒 throttle 批量提交。查询只读取最近一次提交的索引版本,分页的新一页随下一批一次性进入结果;prepend 与 append 都不会重复解析未变化的历史 Markdown。展示缓存与搜索索引互不借用生命周期。
## Runtime and render path
```text
@@ -24,9 +24,11 @@ interface AnchorProps {
onBlur?: FocusEventHandler | undefined
}
type TooltipLabel = string | (() => string)
/**
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text.
* @param props.label - bubble text, or a resolver evaluated only while the bubble is visible.
* @param props.side - placement relative to the anchor (default 'right').
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
@@ -34,7 +36,7 @@ interface AnchorProps {
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
*/
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -46,6 +48,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
}, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
const bubble = useRef<HTMLSpanElement | null>(null)
const resolvedLabel = pos === null
? null
: typeof label === 'function' ? label() : label
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
// a centered bubble near the right edge would clip. Each measurement resets
// the base position before applying a direct style offset, allowing a shorter
@@ -67,7 +72,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
clamp()
window.addEventListener('resize', clamp)
return () => { window.removeEventListener('resize', clamp) }
}, [label, pos])
}, [pos, resolvedLabel])
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -128,7 +133,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
})}
{pos !== null && (
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
{label}
{resolvedLabel}
</span>
)}
</>
@@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
describe('Tooltip', () => {
it('resolves lazy labels only after the bubble becomes visible', () => {
vi.useFakeTimers()
try {
const label = vi.fn(() => 'Timing details')
render(
<Tooltip label={label} delayMs={500}>
<button type="button">anchor</button>
</Tooltip>,
)
expect(label).not.toHaveBeenCalled()
fireEvent.mouseEnter(screen.getByText('anchor'))
act(() => { vi.advanceTimersByTime(499) })
expect(label).not.toHaveBeenCalled()
act(() => { vi.advanceTimersByTime(1) })
expect(screen.getByRole('tooltip').textContent).toBe('Timing details')
expect(label).toHaveBeenCalledOnce()
} finally {
vi.useRealTimers()
}
})
it('can delay pointer hover without delaying keyboard focus', () => {
vi.useFakeTimers()
try {
@@ -24,7 +24,8 @@ import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
} from './trajectory-virtual-rows.ts'
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryTurnModel } from './layout.ts'
import { trajectoryPreviewText } from './trajectory-preview.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
@@ -903,6 +904,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
function recordDisplayText(cell: TrajectoryCellProps): string {
if (isToolCallOnly(cell)) return ''
if (cell.previewMarkdown !== undefined) {
const preview = trajectoryPreviewText(cell.previewMarkdown)
if (cell.text === '') return preview
return preview === '' ? cell.text : `${cell.text} · ${preview}`
}
if (cell.text !== '') return cell.text
const markdown = cell.kind === 'user' || cell.kind === 'context'
? cell.inputDetail
@@ -912,6 +918,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
return markdown === undefined ? '' : trajectoryPreviewText(markdown)
}
function recordResultText(cell: TrajectoryCellProps): string | undefined {
return cell.resultPreviewMarkdown === undefined
? cell.result
: trajectoryPreviewText(cell.resultPreviewMarkdown)
}
function toolCallTextParts(
kind: TrajectoryCellKind,
text: string,
@@ -932,6 +944,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean {
&& cell.text === 'Tool call only'
}
interface RecordPresentationValue {
displayText: string
listDisplayText: string
resultText: string | undefined
toolCallOnly: boolean
toolCallText: ToolCallTextParts | undefined
}
function RecordPresentation({
cell,
children,
}: {
cell: TrajectoryCellProps
children: (value: RecordPresentationValue) => ReactNode
}) {
const displayText = useMemo(
() => recordDisplayText(cell),
[
cell.kind, cell.text, cell.previewMarkdown,
cell.inputDetail, cell.outputDetail, cell.thinkingDetail,
],
)
const resultText = useMemo(
() => recordResultText(cell),
[cell.result, cell.resultPreviewMarkdown],
)
const toolCallOnly = isToolCallOnly(cell)
const toolCallText = toolCallTextParts(cell.kind, displayText)
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
return children({
displayText,
listDisplayText,
resultText,
toolCallOnly,
toolCallText,
})
}
function RecordListText({
displayText,
toolCallOnly,
toolCallText,
}: Pick<RecordPresentationValue, 'displayText' | 'toolCallOnly' | 'toolCallText'>) {
if (toolCallOnly) {
return <span className={css.toolCallOnly}>(tool call only)</span>
}
if (toolCallText === undefined) return displayText || '—'
return (
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
</span>
{toolCallText.args !== undefined && (
<span className={css.toolCallPayload}>
{toolCallText.args}
</span>
)}
</>
)
}
function MarkdownFragment({
text,
rendered,
@@ -2131,15 +2208,12 @@ export function TrajectoryTable({
/>
</tr>
)}
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => {
const displayText = recordDisplayText(record.cell)
const toolCallOnly = isToolCallOnly(record.cell)
const toolCallText = toolCallTextParts(record.cell.kind, displayText)
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => (
<RecordPresentation
key={trajectoryVirtualRecordKey(record)}
cell={record.cell}
>
{({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => {
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
@@ -2169,7 +2243,6 @@ export function TrajectoryTable({
: activeTurn === record.turn
return (
<tr
key={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
@@ -2348,37 +2421,26 @@ export function TrajectoryTable({
)
: (
<span
className={record.cell.result === undefined ? css.contentText : css.resultPreview}
title={record.cell.result === undefined
className={resultText === undefined ? css.contentText : css.resultPreview}
title={resultText === undefined
? listDisplayText
: `${listDisplayText}${record.cell.result}`}
: `${listDisplayText}${resultText}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{toolCallOnly
? <span className={css.toolCallOnly}>(tool call only)</span>
: toolCallText === undefined
? listDisplayText || '—'
: (
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
</span>
{toolCallText.args !== undefined && (
<span className={css.toolCallPayload}>
{toolCallText.args}
</span>
)}
</>
)}
<span className={resultText === undefined ? undefined : css.resultRequest}>
<RecordListText
displayText={displayText}
toolCallOnly={toolCallOnly}
toolCallText={toolCallText}
/>
</span>
{record.cell.result !== undefined && (
{resultText !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}></span>
<span className={record.cell.result === 'No output'
<span className={resultText === 'No output'
? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText}
>
{record.cell.result}
{resultText}
</span>
</span>
)}
@@ -2387,7 +2449,9 @@ export function TrajectoryTable({
</td>
</tr>
)
})}
}}
</RecordPresentation>
))}
{virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td
@@ -687,7 +687,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
return (
<Tooltip
key={span.index}
label={timelineTooltipLabel(span.kind, detail)}
label={() => timelineTooltipLabel(span.kind, detail)}
side="bottom"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
@@ -1,6 +1,6 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
@@ -24,11 +24,13 @@ import {
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import { TrajectorySearchIndex } from './trajectory-search-index.ts'
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
import css from './views.module.css'
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
const SEARCH_INDEX_THROTTLE_MS = 3_000
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
@@ -115,70 +117,6 @@ function addUsage(
}
}
function searchableJson(value: unknown): string {
if (value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function searchMatches(
turns: ReturnType<typeof deriveTrajectoryLayout>,
query: string,
): ReadonlySet<number> | null {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return null
const matches = new Set<number>()
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (cell.requestOnly === true) continue
const blocks = [
...(cell.sourceBlocks ?? []),
...(cell.outputBlocks ?? []),
]
const text = [
turn.turn === null ? 'between turns' : `turn ${turn.turn}`,
group.title,
cell.kind,
cell.kind === 'message' ? 'assistant' : undefined,
cell.text,
cell.inputDetail,
cell.outputDetail,
cell.thinkingDetail,
cell.schemaDetail,
cell.result,
cell.callId,
...blocks.flatMap(block => [
block.type,
block.content,
block.callId,
block.toolName,
block.imageAlt,
]),
searchableJson(cell.messageSource),
searchableJson(cell.promptDetail),
searchableJson(cell.previousPromptDetail),
].filter((value): value is string => typeof value === 'string')
.join('\n')
.toLocaleLowerCase()
if (terms.every(term => text.includes(term))) matches.add(cell.index)
}
}
}
return matches
}
function mergeSearchMatches(
finalized: ReadonlySet<number> | null,
partial: ReadonlySet<number> | null,
): ReadonlySet<number> | null {
if (finalized === null || partial === null) return null
return new Set([...finalized, ...partial])
}
export function TrajectoryView({
useSession, useDuration, loadOlder, setActualDuration,
inspect, onInspectDone,
@@ -190,6 +128,10 @@ export function TrajectoryView({
const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [searchIndex] = useState(() => new TrajectorySearchIndex())
const [searchIndexRevision, setSearchIndexRevision] = useState(0)
const searchIndexTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const searchIndexInitialized = useRef(false)
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
@@ -340,28 +282,59 @@ export function TrajectoryView({
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial],
)
const searchLayouts = useMemo(
() => [finalized.turns, partialSearchTurns] as const,
[finalized, partialSearchTurns],
)
const latestSearchLayouts = useRef(searchLayouts)
latestSearchLayouts.current = searchLayouts
useEffect(() => {
if (!searchIndexInitialized.current) {
searchIndexInitialized.current = true
if (searchIndex.update(searchLayouts)) {
setSearchIndexRevision(revision => revision + 1)
}
return
}
if (searchIndexTimer.current !== null) return
searchIndexTimer.current = setTimeout(() => {
searchIndexTimer.current = null
if (searchIndex.update(latestSearchLayouts.current)) {
setSearchIndexRevision(revision => revision + 1)
}
}, SEARCH_INDEX_THROTTLE_MS)
}, [searchIndex, searchLayouts])
useEffect(() => () => {
if (searchIndexTimer.current !== null) clearTimeout(searchIndexTimer.current)
}, [])
const streamingCells = useMemo(
() => partialSearchTurns.flatMap(turn =>
turn.groups.flatMap(group => group.cells),
),
[partialSearchTurns],
)
const partialSearchMatches = useMemo(
() => searchMatches(partialSearchTurns, searchQuery),
[partialSearchTurns, searchQuery],
)
const searchMatchIndexes = useMemo(
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
const searchMatchRecordIds = useMemo(
() => searchIndex.search(searchQuery),
[searchIndex, searchIndexRevision, searchQuery],
)
const searchMatchIndexes = useMemo(() => {
if (searchMatchRecordIds === null) return null
const indexes = new Set<number>()
for (const turns of searchLayouts) {
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (searchMatchRecordIds.has(trajectoryRecordId(cell))) indexes.add(cell.index)
}
}
}
}
return indexes
}, [searchLayouts, searchMatchRecordIds])
const timelineRange = timelineSelection
const timelineFocusIndexes = useMemo(
() => timelineRange === null
@@ -12,7 +12,6 @@ import type {
ToolCallBlock,
ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type {
TrajectoryCellProps,
TrajectorySourceBlock,
@@ -70,9 +69,6 @@ interface TurnBucket {
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
type InputNode = Extract<
ConversationSnapshot['nodes'][number],
{ kind: 'user' | 'context' }
@@ -110,10 +106,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
function inputCellDetail(node: InputNode): Pick<
TrajectoryCellProps,
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt'
| 'text'
| 'previewMarkdown'
| 'sourceSeq'
| 'messageSource'
| 'inputDetail'
| 'sourceBlocks'
| 'timeSeconds'
| 'startedAt'
> {
const previewMarkdown = previewContent(node.content)
return {
text: summarizeContent(node.content),
text: '',
...(previewMarkdown === undefined ? {} : { previewMarkdown }),
sourceSeq: node.seq,
messageSource: node.source,
inputDetail: detailContent(node.content),
@@ -293,7 +298,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
? request.error ?? 'Compaction failed'
: request.summary === undefined
? 'Context compacted'
: summarizeContent(request.summary),
: '',
...(request.status === 'complete' && request.summary !== undefined
? previewContentProperty(request.summary)
: {}),
sourceSeq: request.startSeq,
...(request.summary === undefined
? {}
@@ -377,6 +385,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (node.kind === 'tool-result') {
if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name
const resultPreview = summarizeResult(node)
const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}),
@@ -386,13 +395,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index,
kind: 'tool',
sourceSeq: node.seq,
text: node.call !== null
...(node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node),
: resultAsText(resultPreview)),
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
outputDetail: detailResult(node),
outputBlocks: node.content.map(block => sourceBlock(block)),
result: summarizeResult(node),
...resultPreview,
callId: node.callId,
isError: node.isError,
timeSeconds: durationSeconds(node.time, node.callTime),
@@ -440,7 +449,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
cell: {
index: ++index,
kind: 'tool',
text: summarizeCall(call.name, call.argsRaw),
...summarizeCall(call.name, call.argsRaw),
inputDetail: call.argsRaw,
callId: call.callId,
timeSeconds: null,
@@ -650,11 +659,14 @@ function expandAssistant(
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''
? summarizeText(messageText)
text: messageText !== '' || thinkingText !== ''
? ''
: summarizeAssistantActivity(node.blocks),
...(messageText !== ''
? { previewMarkdown: messageText }
: thinkingText !== ''
? summarizeText(thinkingText)
: summarizeAssistantActivity(node.blocks),
? { previewMarkdown: thinkingText }
: {}),
...(messageText !== '' ? { outputDetail: messageText } : {}),
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
@@ -681,6 +693,7 @@ function expandAssistant(
: durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId)
const resultPreview = result === undefined ? undefined : summarizeResult(result)
out.push({
absTime: callAbs,
toolName: block.name,
@@ -688,14 +701,14 @@ function expandAssistant(
...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: {
index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw),
...summarizeCall(block.name, block.argsRaw),
inputDetail: block.argsRaw,
callId: block.callId,
...(result !== undefined
? {
outputDetail: detailResult(result),
outputBlocks: result.content.map(block => sourceBlock(block)),
result: summarizeResult(result),
...resultPreview,
isError: result.isError,
}
: {}),
@@ -919,6 +932,7 @@ function expandSubCalls(
let index = startIndex
for (const sub of subs) {
const settled = 'kind' in sub
const resultPreview = settled ? summarizeResult(sub) : undefined
const laid: LaidCell = {
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
@@ -927,9 +941,11 @@ function expandSubCalls(
index: ++index,
kind: 'subtool',
callId: sub.callId,
text: settled
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub))
: summarizeCall(sub.name, sub.argsRaw),
...(settled
? (sub.call !== null
? summarizeCall(sub.call.name, sub.call.argsRaw)
: resultAsText(resultPreview))
: summarizeCall(sub.name, sub.argsRaw)),
...(settled
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
: { inputDetail: sub.argsRaw }),
@@ -937,7 +953,7 @@ function expandSubCalls(
? {
outputDetail: detailResult(sub),
outputBlocks: sub.content.map(block => sourceBlock(block)),
result: summarizeResult(sub),
...resultPreview,
isError: sub.isError,
}
: {}),
@@ -958,22 +974,39 @@ function expandSubCalls(
return out
}
function summarizeCall(name: string, argsRaw: string): string {
const args = trajectoryPreviewText(argsRaw)
if (args === '') return name
return `${name} · ${args}`
function summarizeCall(
name: string,
argsRaw: string,
): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
return {
text: name,
...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }),
}
}
function summarizeResult(node: ToolResultNode): string {
function summarizeResult(
node: ToolResultNode,
): Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> {
if (node.isError) {
return node.error?.code ?? 'error'
return { result: node.error?.code ?? 'error' }
}
for (const block of node.content) {
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
return summarizeText(block.text)
return { result: '', resultPreviewMarkdown: block.text }
}
}
return 'No output'
return { result: 'No output' }
}
function resultAsText(
result: Pick<TrajectoryCellProps, 'result' | 'resultPreviewMarkdown'> | undefined,
): Pick<TrajectoryCellProps, 'text' | 'previewMarkdown'> {
return {
text: result?.result ?? '',
...(result?.resultPreviewMarkdown === undefined
? {}
: { previewMarkdown: result.resultPreviewMarkdown }),
}
}
function detailResult(node: ToolResultNode): string {
@@ -1009,28 +1042,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s
.join('\n')
}
function summarizeContent(content: readonly { type: string; text?: string }[]): string {
function previewContent(
content: readonly { type: string; text?: string }[],
): string | undefined {
for (const block of content) {
if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text)
if (block.type === 'text' && typeof block.text === 'string') return block.text
}
return ''
return undefined
}
function summarizeText(text: string): string {
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 = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
return source.length < text.length || preview.length < compact.length
? `${preview}`
: preview
function previewContentProperty(
content: readonly { type: string; text?: string }[],
): Pick<TrajectoryCellProps, 'previewMarkdown'> {
const previewMarkdown = previewContent(content)
return previewMarkdown === undefined ? {} : { previewMarkdown }
}
@@ -0,0 +1,20 @@
/** Bounded Markdown-to-text projection shared by trajectory consumers. */
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
const PREVIEW_SOURCE_CHARACTERS = 2_048
const PREVIEW_OUTPUT_CHARACTERS = 512
/**
* Build a bounded one-line preview without parsing the complete Markdown document.
* @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 = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim()
const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd()
return source.length < text.length || preview.length < compact.length
? `${preview}`
: preview
}
@@ -40,8 +40,10 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
/** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */
text: string
/** Raw Markdown source converted into the single-line summary at its consumer. */
previewMarkdown?: string
/** Whether this user record opens a new model turn. */
opensTurn?: boolean
/** Source session-event seq for cross-record navigation. */
@@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
assistantMetrics?: AssistantMetricDetail
/** Tool-only result summary paired with the call in the same record. */
result?: string
/** Raw Markdown source converted into the tool-result summary at its consumer. */
resultPreviewMarkdown?: string
/** Tool call id used to link message source blocks to tool records. */
callId?: string
/** Tool-only result failure state. */
@@ -0,0 +1,133 @@
/** Incremental full-text index for the trajectory ledger. */
import type { TrajectoryTurnModel } from './layout.ts'
import type { TrajectoryCellProps } from './trajectory-record.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import { trajectoryPreviewText } from './trajectory-preview.ts'
interface SearchEntry {
readonly sources: readonly string[]
readonly text: string
}
function searchableJson(value: unknown): string {
if (value === undefined) return ''
try {
return JSON.stringify(value)
} catch {
return ''
}
}
function sameSources(left: readonly string[], right: readonly string[]): boolean {
return left.length === right.length && left.every((value, index) => value === right[index])
}
function markdownPreview(cell: TrajectoryCellProps): string {
if (cell.previewMarkdown === undefined) return ''
const preview = trajectoryPreviewText(cell.previewMarkdown)
if (cell.text === '') return preview
return preview === '' ? cell.text : `${cell.text} · ${preview}`
}
function resultPreview(cell: TrajectoryCellProps): string {
return cell.resultPreviewMarkdown === undefined
? cell.result ?? ''
: trajectoryPreviewText(cell.resultPreviewMarkdown)
}
function recordSources(
turn: number | null,
group: string,
cell: TrajectoryCellProps,
): readonly string[] {
const blocks = [
...(cell.sourceBlocks ?? []),
...(cell.outputBlocks ?? []),
]
return [
turn === null ? 'between turns' : `turn ${turn}`,
group,
cell.kind,
cell.kind === 'message' ? 'assistant' : '',
cell.text,
cell.previewMarkdown ?? '',
cell.inputDetail ?? '',
cell.outputDetail ?? '',
cell.thinkingDetail ?? '',
cell.schemaDetail ?? '',
cell.result ?? '',
cell.resultPreviewMarkdown ?? '',
cell.callId ?? '',
...blocks.flatMap(block => [
block.type,
block.content,
block.callId ?? '',
block.toolName ?? '',
block.imageAlt ?? '',
]),
searchableJson(cell.messageSource),
searchableJson(cell.promptDetail),
searchableJson(cell.previousPromptDetail),
]
}
/** Session-view-local index that reparses Markdown only when one record's source changes. */
export class TrajectorySearchIndex {
private readonly entries = new Map<string, SearchEntry>()
private layouts: readonly (readonly TrajectoryTurnModel[])[] | undefined
/**
* Incrementally synchronize one or more current trajectory layout slices.
* @param layouts - Finalized and optional streaming layouts from the same view.
* @returns Whether the indexed layout version changed.
*/
update(layouts: readonly (readonly TrajectoryTurnModel[])[]): boolean {
if (this.layouts === layouts) return false
this.layouts = layouts
const seen = new Set<string>()
for (const turns of layouts) {
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) {
if (cell.requestOnly === true) continue
const id = trajectoryRecordId(cell)
const sources = recordSources(turn.turn, group.title, cell)
const previous = this.entries.get(id)
const entry = previous !== undefined && sameSources(previous.sources, sources)
? previous
: {
sources,
text: [
...sources,
markdownPreview(cell),
resultPreview(cell),
].join('\n').toLocaleLowerCase(),
}
this.entries.set(id, entry)
seen.add(id)
}
}
}
}
for (const id of this.entries.keys()) {
if (!seen.has(id)) this.entries.delete(id)
}
return true
}
/**
* Match a query against the latest committed index version.
* @param query - Space-separated case-insensitive search terms.
* @returns Matching stable record identities, or `null` without a query.
*/
search(query: string): ReadonlySet<string> | null {
const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean)
if (terms.length === 0) return null
const matches = new Set<string>()
for (const [id, entry] of this.entries) {
if (terms.every(term => entry.text.includes(term))) matches.add(id)
}
return matches
}
}