perf(ui-trajectory): defer trajectory text processing

This commit is contained in:
imccyu
2026-08-10 23:51:18 +08:00
parent 58ea69f64f
commit e27dba8457
12 changed files with 415 additions and 168 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # 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: # 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 # 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.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526
2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e 2026-08-09-client-conversation-node-assembly.zh.md: 2ddb14c35b3ac5aeba5b00e7d56b3a4e69a97adb

View File

@@ -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 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 ## Runtime and render path
```text ```text

View File

@@ -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 slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。 Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。
Trajectory 的 stage/layout 只保留原始摘要来源和结构数据,不解析 Markdown。Table 的稳定 Record presentation 按内容 memo 单行摘要,并把同一结果用于正文、title 与 aria-label;Detail 只渲染当前选中记录。Timeline 的时序标签只在延迟 Tooltip 实际打开后执行格式化。搜索拥有独立的 per-view `TrajectorySearchIndex`,按稳定 Record identity 保存来源签名和标准化文本;初始窗口立即建立索引,后续新增或变化的 Record 由三秒 throttle 批量提交。查询只读取最近一次提交的索引版本,分页的新一页随下一批一次性进入结果;prepend 与 append 都不会重复解析未变化的历史 Markdown。展示缓存与搜索索引互不借用生命周期。
## Runtime and render path ## Runtime and render path
```text ```text

View File

@@ -24,9 +24,11 @@ interface AnchorProps {
onBlur?: FocusEventHandler | undefined onBlur?: FocusEventHandler | undefined
} }
type TooltipLabel = string | (() => string)
/** /**
* Attach a hover/focus tooltip to an anchor element. * 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.side - placement relative to the anchor (default 'right').
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so * @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. * @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. * @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) const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an // React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref. // 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]) }, [childRef])
const [pos, setPos] = useState<{ x: number; y: number } | null>(null) const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
const bubble = useRef<HTMLSpanElement | 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 // Horizontal viewport clamp: fixed positioning knows nothing about edges, so
// a centered bubble near the right edge would clip. Each measurement resets // a centered bubble near the right edge would clip. Each measurement resets
// the base position before applying a direct style offset, allowing a shorter // 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() clamp()
window.addEventListener('resize', clamp) window.addEventListener('resize', clamp)
return () => { window.removeEventListener('resize', clamp) } return () => { window.removeEventListener('resize', clamp) }
}, [label, pos]) }, [pos, resolvedLabel])
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null) const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after // Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it). // 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 && ( {pos !== null && (
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip"> <span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
{label} {resolvedLabel}
</span> </span>
)} )}
</> </>

View File

@@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup) afterEach(cleanup)
describe('Tooltip', () => { 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', () => { it('can delay pointer hover without delaying keyboard focus', () => {
vi.useFakeTimers() vi.useFakeTimers()
try { try {

View File

@@ -24,7 +24,8 @@ import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey, groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
} from './trajectory-virtual-rows.ts' } from './trajectory-virtual-rows.ts'
import type { TrajectoryVirtualRow } 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' import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2 const BOTTOM_FOLLOW_THRESHOLD_PX = 2
@@ -903,6 +904,11 @@ 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.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 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
@@ -912,6 +918,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string {
return markdown === undefined ? '' : trajectoryPreviewText(markdown) return markdown === undefined ? '' : trajectoryPreviewText(markdown)
} }
function recordResultText(cell: TrajectoryCellProps): string | undefined {
return cell.resultPreviewMarkdown === undefined
? cell.result
: trajectoryPreviewText(cell.resultPreviewMarkdown)
}
function toolCallTextParts( function toolCallTextParts(
kind: TrajectoryCellKind, kind: TrajectoryCellKind,
text: string, text: string,
@@ -932,6 +944,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean {
&& cell.text === 'Tool call only' && 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({ function MarkdownFragment({
text, text,
rendered, rendered,
@@ -2131,15 +2208,12 @@ export function TrajectoryTable({
/> />
</tr> </tr>
)} )}
{renderedRecords.map(({ record, position, terminalRequestBoundary }) => { {renderedRecords.map(({ record, position, terminalRequestBoundary }) => (
const displayText = recordDisplayText(record.cell) <RecordPresentation
const toolCallOnly = isToolCallOnly(record.cell) key={trajectoryVirtualRecordKey(record)}
const toolCallText = toolCallTextParts(record.cell.kind, displayText) cell={record.cell}
const listDisplayText = toolCallOnly >
? '(tool call only)' {({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => {
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system' const isInitialSystem = record.cell.kind === 'system'
@@ -2169,7 +2243,6 @@ export function TrajectoryTable({
: activeTurn === record.turn : activeTurn === record.turn
return ( return (
<tr <tr
key={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0} tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1} aria-rowindex={position + 1}
aria-label={isCollapsedSummary aria-label={isCollapsedSummary
@@ -2348,37 +2421,26 @@ export function TrajectoryTable({
) )
: ( : (
<span <span
className={record.cell.result === undefined ? css.contentText : css.resultPreview} className={resultText === undefined ? css.contentText : css.resultPreview}
title={record.cell.result === undefined title={resultText === undefined
? listDisplayText ? listDisplayText
: `${listDisplayText} → ${record.cell.result}`} : `${listDisplayText} → ${resultText}`}
> >
<span className={record.cell.result === undefined ? undefined : css.resultRequest}> <span className={resultText === undefined ? undefined : css.resultRequest}>
{toolCallOnly <RecordListText
? <span className={css.toolCallOnly}>(tool call only)</span> displayText={displayText}
: toolCallText === undefined toolCallOnly={toolCallOnly}
? listDisplayText || '—' toolCallText={toolCallText}
: ( />
<>
<span className={css.toolCallNameTypeface}>
{toolCallText.name || '—'}
</span>
{toolCallText.args !== undefined && (
<span className={css.toolCallPayload}>
{toolCallText.args}
</span>
)}
</>
)}
</span> </span>
{record.cell.result !== undefined && ( {resultText !== undefined && (
<span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}> <span className={record.cell.isError ? `${css.inlineResult} ${css.error}` : css.inlineResult}>
<span className={css.arrow}>→</span> <span className={css.arrow}>→</span>
<span className={record.cell.result === 'No output' <span className={resultText === 'No output'
? `${css.inlineResultText} ${css.noOutputText}` ? `${css.inlineResultText} ${css.noOutputText}`
: css.inlineResultText} : css.inlineResultText}
> >
{record.cell.result} {resultText}
</span> </span>
</span> </span>
)} )}
@@ -2387,7 +2449,9 @@ export function TrajectoryTable({
</td> </td>
</tr> </tr>
) )
})} }}
</RecordPresentation>
))}
{virtualBottom > 0 && ( {virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true"> <tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td <td

View File

@@ -687,7 +687,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
return ( return (
<Tooltip <Tooltip
key={span.index} key={span.index}
label={timelineTooltipLabel(span.kind, detail)} label={() => timelineTooltipLabel(span.kind, detail)}
side="bottom" side="bottom"
delayMs={TIMELINE_TOOLTIP_DELAY_MS} delayMs={TIMELINE_TOOLTIP_DELAY_MS}
> >

View File

@@ -1,6 +1,6 @@
/** Trajectory view: compact summary over a turn-aware event ledger. */ /** 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 { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type { import type {
@@ -24,11 +24,13 @@ import {
type TrajectoryTimeRange, type TrajectoryTimeRange,
} from './timeline.ts' } from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts' import { trajectoryRecordId } from './trajectory-record.ts'
import { TrajectorySearchIndex } from './trajectory-search-index.ts'
import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts'
import css from './views.module.css' import css from './views.module.css'
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set() const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set() const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
const SEARCH_INDEX_THROTTLE_MS = 3_000
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number { function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0 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({ export function TrajectoryView({
useSession, useDuration, loadOlder, setActualDuration, useSession, useDuration, loadOlder, setActualDuration,
inspect, onInspectDone, inspect, onInspectDone,
@@ -190,6 +128,10 @@ export function TrajectoryView({
const actualDuration = useDuration(value => value) const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false) const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('') 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 [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number readonly index: number
@@ -340,28 +282,59 @@ export function TrajectoryView({
const timelineMode: TrajectoryTimelineMode = actualDuration const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration' ? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence' : actualTime ? 'time' : 'sequence'
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const partialSearchTurns = useMemo( const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex), () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial], [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( const streamingCells = useMemo(
() => partialSearchTurns.flatMap(turn => () => partialSearchTurns.flatMap(turn =>
turn.groups.flatMap(group => group.cells), turn.groups.flatMap(group => group.cells),
), ),
[partialSearchTurns], [partialSearchTurns],
) )
const partialSearchMatches = useMemo( const searchMatchRecordIds = useMemo(
() => searchMatches(partialSearchTurns, searchQuery), () => searchIndex.search(searchQuery),
[partialSearchTurns, searchQuery], [searchIndex, searchIndexRevision, searchQuery],
)
const searchMatchIndexes = useMemo(
() => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches),
[finalizedSearchMatches, partialSearchMatches],
) )
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 timelineRange = timelineSelection
const timelineFocusIndexes = useMemo( const timelineFocusIndexes = useMemo(
() => timelineRange === null () => timelineRange === null

View File

@@ -12,7 +12,6 @@ import type {
ToolCallBlock, ToolCallBlock,
ToolResultNode, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { import type {
TrajectoryCellProps, TrajectoryCellProps,
TrajectorySourceBlock, TrajectorySourceBlock,
@@ -70,9 +69,6 @@ interface TurnBucket {
type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }> type AssistantRequestView = Extract<RequestView, { purpose: 'assistant' }>
type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }> type CompactionRequestView = Extract<RequestView, { purpose: 'compaction' }>
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' | 'context' } { kind: 'user' | 'context' }
@@ -110,10 +106,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number {
function inputCellDetail(node: InputNode): Pick< function inputCellDetail(node: InputNode): Pick<
TrajectoryCellProps, TrajectoryCellProps,
'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt' | 'text'
| 'previewMarkdown'
| 'sourceSeq'
| 'messageSource'
| 'inputDetail'
| 'sourceBlocks'
| 'timeSeconds'
| 'startedAt'
> { > {
const previewMarkdown = previewContent(node.content)
return { return {
text: summarizeContent(node.content), text: '',
...(previewMarkdown === undefined ? {} : { previewMarkdown }),
sourceSeq: node.seq, sourceSeq: node.seq,
messageSource: node.source, messageSource: node.source,
inputDetail: detailContent(node.content), inputDetail: detailContent(node.content),
@@ -293,7 +298,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
? request.error ?? 'Compaction failed' ? request.error ?? 'Compaction failed'
: request.summary === undefined : request.summary === undefined
? 'Context compacted' ? 'Context compacted'
: summarizeContent(request.summary), : '',
...(request.status === 'complete' && request.summary !== undefined
? previewContentProperty(request.summary)
: {}),
sourceSeq: request.startSeq, sourceSeq: request.startSeq,
...(request.summary === undefined ...(request.summary === undefined
? {} ? {}
@@ -377,6 +385,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
if (node.kind === 'tool-result') { if (node.kind === 'tool-result') {
if (!emittedCallIds.has(node.callId)) { if (!emittedCallIds.has(node.callId)) {
const toolName = node.call?.name const toolName = node.call?.name
const resultPreview = summarizeResult(node)
const laidList: LaidCell[] = [{ const laidList: LaidCell[] = [{
absTime: finiteTime(node.callTime ?? node.time), absTime: finiteTime(node.callTime ?? node.time),
...(toolName !== undefined ? { toolName } : {}), ...(toolName !== undefined ? { toolName } : {}),
@@ -386,13 +395,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
index: ++index, index: ++index,
kind: 'tool', kind: 'tool',
sourceSeq: node.seq, sourceSeq: node.seq,
text: node.call !== null ...(node.call !== null
? summarizeCall(node.call.name, node.call.argsRaw) ? summarizeCall(node.call.name, node.call.argsRaw)
: summarizeResult(node), : resultAsText(resultPreview)),
...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}),
outputDetail: detailResult(node), outputDetail: detailResult(node),
outputBlocks: node.content.map(block => sourceBlock(block)), outputBlocks: node.content.map(block => sourceBlock(block)),
result: summarizeResult(node), ...resultPreview,
callId: node.callId, callId: node.callId,
isError: node.isError, isError: node.isError,
timeSeconds: durationSeconds(node.time, node.callTime), timeSeconds: durationSeconds(node.time, node.callTime),
@@ -440,7 +449,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
cell: { cell: {
index: ++index, index: ++index,
kind: 'tool', kind: 'tool',
text: summarizeCall(call.name, call.argsRaw), ...summarizeCall(call.name, call.argsRaw),
inputDetail: call.argsRaw, inputDetail: call.argsRaw,
callId: call.callId, callId: call.callId,
timeSeconds: null, timeSeconds: null,
@@ -650,11 +659,14 @@ function expandAssistant(
recordId: `assistant\u0000${node.turn}\u0000${node.step}`, recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message', kind: 'message',
sourceSeq: node.seq, sourceSeq: node.seq,
text: messageText !== '' text: messageText !== '' || thinkingText !== ''
? summarizeText(messageText) ? ''
: summarizeAssistantActivity(node.blocks),
...(messageText !== ''
? { previewMarkdown: messageText }
: thinkingText !== '' : thinkingText !== ''
? summarizeText(thinkingText) ? { previewMarkdown: thinkingText }
: summarizeAssistantActivity(node.blocks), : {}),
...(messageText !== '' ? { outputDetail: messageText } : {}), ...(messageText !== '' ? { outputDetail: messageText } : {}),
...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}), ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}),
sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)), sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)),
@@ -681,6 +693,7 @@ function expandAssistant(
: durationSeconds(result.time, result.callTime) : durationSeconds(result.time, result.callTime)
const callAbs = finiteTime(callStarts.get(block.callId)) const callAbs = finiteTime(callStarts.get(block.callId))
const call = calls.get(block.callId) const call = calls.get(block.callId)
const resultPreview = result === undefined ? undefined : summarizeResult(result)
out.push({ out.push({
absTime: callAbs, absTime: callAbs,
toolName: block.name, toolName: block.name,
@@ -688,14 +701,14 @@ function expandAssistant(
...(call === undefined ? {} : { subCalls: call.subCalls }), ...(call === undefined ? {} : { subCalls: call.subCalls }),
cell: { cell: {
index: ++index, kind: 'tool', index: ++index, kind: 'tool',
text: summarizeCall(block.name, block.argsRaw), ...summarizeCall(block.name, block.argsRaw),
inputDetail: block.argsRaw, inputDetail: block.argsRaw,
callId: block.callId, callId: block.callId,
...(result !== undefined ...(result !== undefined
? { ? {
outputDetail: detailResult(result), outputDetail: detailResult(result),
outputBlocks: result.content.map(block => sourceBlock(block)), outputBlocks: result.content.map(block => sourceBlock(block)),
result: summarizeResult(result), ...resultPreview,
isError: result.isError, isError: result.isError,
} }
: {}), : {}),
@@ -919,6 +932,7 @@ function expandSubCalls(
let index = startIndex let index = startIndex
for (const sub of subs) { for (const sub of subs) {
const settled = 'kind' in sub const settled = 'kind' in sub
const resultPreview = settled ? summarizeResult(sub) : undefined
const laid: LaidCell = { const laid: LaidCell = {
absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time),
toolName: settled ? sub.call?.name ?? sub.callId : sub.name, toolName: settled ? sub.call?.name ?? sub.callId : sub.name,
@@ -927,9 +941,11 @@ function expandSubCalls(
index: ++index, index: ++index,
kind: 'subtool', kind: 'subtool',
callId: sub.callId, callId: sub.callId,
text: settled ...(settled
? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) ? (sub.call !== null
: summarizeCall(sub.name, sub.argsRaw), ? summarizeCall(sub.call.name, sub.call.argsRaw)
: resultAsText(resultPreview))
: summarizeCall(sub.name, sub.argsRaw)),
...(settled ...(settled
? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {})
: { inputDetail: sub.argsRaw }), : { inputDetail: sub.argsRaw }),
@@ -937,7 +953,7 @@ function expandSubCalls(
? { ? {
outputDetail: detailResult(sub), outputDetail: detailResult(sub),
outputBlocks: sub.content.map(block => sourceBlock(block)), outputBlocks: sub.content.map(block => sourceBlock(block)),
result: summarizeResult(sub), ...resultPreview,
isError: sub.isError, isError: sub.isError,
} }
: {}), : {}),
@@ -958,22 +974,39 @@ function expandSubCalls(
return out return out
} }
function summarizeCall(name: string, argsRaw: string): string { function summarizeCall(
const args = trajectoryPreviewText(argsRaw) name: string,
if (args === '') return name argsRaw: string,
return `${name} · ${args}` ): 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) { if (node.isError) {
return node.error?.code ?? 'error' return { result: node.error?.code ?? 'error' }
} }
for (const block of node.content) { for (const block of node.content) {
if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { 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 { function detailResult(node: ToolResultNode): string {
@@ -1009,28 +1042,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s
.join('\n') .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) { 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 { function previewContentProperty(
return trajectoryPreviewText(text) content: readonly { type: string; text?: string }[],
} ): Pick<TrajectoryCellProps, 'previewMarkdown'> {
const previewMarkdown = previewContent(content)
/** return previewMarkdown === undefined ? {} : { previewMarkdown }
* 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
} }

View File

@@ -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
}

View File

@@ -40,8 +40,10 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** Projection-stable identity when no single source event owns the record lifecycle. */ /** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string recordId?: string
kind: TrajectoryCellKind kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */ /** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */
text: string 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. */ /** Whether this user record opens a new model turn. */
opensTurn?: boolean opensTurn?: boolean
/** Source session-event seq for cross-record navigation. */ /** Source session-event seq for cross-record navigation. */
@@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
assistantMetrics?: AssistantMetricDetail assistantMetrics?: AssistantMetricDetail
/** Tool-only result summary paired with the call in the same record. */ /** Tool-only result summary paired with the call in the same record. */
result?: string 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. */ /** Tool call id used to link message source blocks to tool records. */
callId?: string callId?: string
/** Tool-only result failure state. */ /** Tool-only result failure state. */

View File

@@ -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
}
}