Просмотр исходного кода

perf(ui-trajectory): defer trajectory text processing

imccyu 2 месяцев назад
Родитель
Сommit
e27dba8457

+ 2 - 2
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml

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

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md

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

+ 2 - 0
.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md

@@ -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 的 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
 
 ```text

+ 9 - 4
packages/client/ui-primitives/src/Tooltip.tsx

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

+ 21 - 0
packages/client/ui-primitives/tests/tooltip.spec.tsx

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

+ 99 - 35
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

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

+ 1 - 1
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx

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

+ 49 - 76
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

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

+ 72 - 49
packages/client/ui-trajectory/src/client/layout.ts

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

+ 20 - 0
packages/client/ui-trajectory/src/client/trajectory-preview.ts

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

+ 5 - 1
packages/client/ui-trajectory/src/client/trajectory-record.ts

@@ -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. */

+ 133 - 0
packages/client/ui-trajectory/src/client/trajectory-search-index.ts

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