Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

Resolve translation-pairing hashes after the merge and adapt the
math-rendering e2e fixture to the simplified turn/start event shape.
This commit is contained in:
_Kerman
2026-08-05 21:42:33 +08:00
60 changed files with 3207 additions and 376 deletions

View File

@@ -13,6 +13,7 @@
}
.tablePane {
position: relative;
flex: 1;
min-width: 0;
overflow-x: hidden;
@@ -21,6 +22,53 @@
container: trajectory-table / inline-size;
}
.historyLoading {
position: sticky;
z-index: 5;
top: 0;
height: 0;
overflow: visible;
pointer-events: none;
}
.historyLoadingBar {
display: flex;
width: 100%;
height: 30px;
align-items: center;
justify-content: center;
gap: 6px;
box-sizing: border-box;
border-bottom: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
}
.historyLoadingSpinner {
width: 10px;
height: 10px;
box-sizing: border-box;
border: 1.5px solid var(--dsw-alias-border-l2);
border-top-color: var(--dsw-alias-state-business-primary);
border-radius: 50%;
animation: history-loading-spin 700ms linear infinite;
}
.table:not([data-scroll-ready='true']) {
visibility: hidden;
}
@keyframes history-loading-spin {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.historyLoadingSpinner {
animation: none;
}
}
.table {
--trajectory-turn-accent: color-mix(
in srgb,
@@ -79,7 +127,17 @@
white-space: nowrap;
}
.table tbody tr:not([data-collapsed-summary]) {
.table tbody .virtualSpacer {
pointer-events: none;
}
.table tbody .virtualSpacer td {
height: var(--trajectory-virtual-spacer-height);
padding: 0;
border: 0;
}
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]) {
cursor: default;
outline: none;
transition:
@@ -91,7 +149,7 @@
opacity: 0.24;
}
.table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {
.table tbody tr:not([data-collapsed-summary]):not([data-virtual-spacer]):not([data-selected='true']):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -106,7 +164,7 @@
border-bottom: 0;
}
.table tbody tr[data-request-only='true']:last-child td {
.table tbody tr[data-terminal-request-boundary='true'] td {
/* Retain the lower half of the 16px boundary marker at the table's end. */
height: 9px;
}
@@ -620,6 +678,10 @@
white-space: nowrap;
}
.toolCallOnly {
color: var(--dsw-alias-label-tertiary);
}
.table tbody tr[data-collapsed-summary='turn'] td,
.table tbody tr[data-collapsed-summary='assistant'] td {
height: 20px;
@@ -936,6 +998,17 @@
overflow: auto;
}
.summaryScrollRegion {
--dsh-scrollbar-thumb: transparent;
--dsh-scrollbar-thumb-hover: transparent;
}
.summaryScrollRegion:hover,
.summaryScrollRegion:focus-within {
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
}
.compactedSummary .markdownPayload {
padding-right: 18px;
}
@@ -1023,7 +1096,6 @@
margin: 0;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -1033,7 +1105,6 @@
color: inherit;
cursor: pointer;
font: inherit;
font-variant-numeric: tabular-nums;
user-select: text;
}

View File

@@ -2,6 +2,7 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import {
IconChevronRightOutline14,
IconSettingsOutline16,
@@ -18,11 +19,19 @@ import type {
import type {
AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
import { formatElapsedSeconds, trajectoryRecordId } from './trajectory-record.ts'
import {
groupTrajectoryVirtualRows, trajectoryVirtualRecordKey,
} from './trajectory-virtual-rows.ts'
import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts'
import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
import css from './TrajectoryTable.module.css'
const BOTTOM_FOLLOW_THRESHOLD_PX = 2
const OLDER_LOAD_THRESHOLD_PX = 48
const VIRTUALIZATION_THRESHOLD = 100
const VIRTUAL_OVERSCAN_ROWS = 12
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
const KIND_LABEL: Record<TrajectoryCellKind, string> = {
system: 'SYSTEM',
@@ -117,6 +126,30 @@ interface TableRecord {
collapsedSummaryKind?: 'turn' | 'assistant'
}
interface VirtualRowStructure {
height: number
key: string
}
function useStableVirtualRowStructure(
rows: readonly TrajectoryVirtualRow<TableRecord>[],
): readonly VirtualRowStructure[] {
const cache = useRef<{
rows: readonly TrajectoryVirtualRow<TableRecord>[]
structure: readonly VirtualRowStructure[]
}>({ rows: [], structure: [] })
if (cache.current.rows === rows) return cache.current.structure
const structure = cache.current.structure.length === rows.length
&& rows.every((row, index) => {
const previous = cache.current.structure[index]
return previous?.key === row.key && previous.height === row.height
})
? cache.current.structure
: rows.map(row => ({ key: row.key, height: row.height }))
cache.current = { rows, structure }
return structure
}
type DetailTab =
| 'system-prompt'
| 'tools'
@@ -150,9 +183,8 @@ interface ToolCallTextParts {
interface SelectedRequest {
turn: number | null
section: number
number: number
group: string
seq?: number
}
interface DetailsResizeDrag {
@@ -195,6 +227,16 @@ type RequestBoundaryStyle = CSSProperties & {
'--request-boundary-offset': string
}
type VirtualSpacerStyle = CSSProperties & {
'--trajectory-virtual-spacer-height': string
}
interface OlderLoadAnchor {
readonly historyStartSeq: number | undefined
readonly scrollHeight: number
readonly scrollTop: number
}
function clampDetailsWidth(width: number, splitWidth: number): number {
const maxWidth = Math.max(
DETAILS_MIN_WIDTH,
@@ -228,6 +270,15 @@ function formatStartedAt(timestamp: number | null): string {
return `${day} ${time}`
}
/** Whether a click lands on an active text selection and should keep it. */
function clickSelectsText(target: Node): boolean {
const selection = window.getSelection()
return selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(target)
}
function StartedAtValue({ timestamp }: { timestamp: number | null }) {
const [showUnix, setShowUnix] = useState(false)
if (timestamp === null || !Number.isFinite(timestamp)) return <dd>Not available</dd>
@@ -238,13 +289,7 @@ function StartedAtValue({ timestamp }: { timestamp: number | null }) {
className={css.timestampToggle}
title={showUnix ? 'Show local time' : 'Show Unix timestamp'}
onClick={(event) => {
const selection = window.getSelection()
if (
selection !== null
&& !selection.isCollapsed
&& selection.rangeCount > 0
&& selection.getRangeAt(0).intersectsNode(event.currentTarget)
) return
if (clickSelectsText(event.currentTarget)) return
setShowUnix(current => !current)
}}
>
@@ -302,6 +347,8 @@ export interface TrajectoryTableProps {
requestNumbers?: readonly TrajectoryRequestNumber[]
/** Grouped records in display order. */
turns: readonly TrajectoryTurnModel[]
/** In-flight cells whose content replaces the matching structural record index. */
streamingCells?: readonly TrajectoryCellProps[]
/** Record indexes emphasized by the active timeline focus. */
timelineFocusIndexes?: ReadonlySet<number> | null
/** Record indexes retained by the active live search, or null without a query. */
@@ -312,16 +359,26 @@ export interface TrajectoryTableProps {
onRecordSelect?: (index: number) => void
/** One externally requested record selection; a new object repeats the request. */
recordSelection?: { readonly index: number } | null
/** One externally requested record focus without changing inspector selection. */
recordFocus?: { readonly index: number } | null
/** Whether the initial history tail is still loading. */
historyLoading?: boolean
/** First loaded raw event, used to preserve scroll position after prepending a page. */
historyStartSeq?: number | undefined
/** Whether one older history page can be requested. */
hasOlderRecords?: boolean
/** Load one older history page. */
onLoadOlder?: () => Promise<boolean>
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */
collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */
onToggleTurn: (turn: number) => void
/** Assistant record indexes whose tool calls are folded. */
collapsedAssistants: ReadonlySet<number>
/** Stable Assistant record ids whose tool calls are folded. */
collapsedAssistants: ReadonlySet<string>
/** Toggle tool calls under one assistant record. */
onToggleAssistant: (index: number) => void
onToggleAssistant: (id: string) => void
/** One-shot cross-view inspect: open and scroll to this call's record. */
inspectCallId?: string | null
/** Acknowledge a consumed (or unresolvable) inspect request. */
@@ -492,7 +549,6 @@ function collapseTurnRecords(
records: readonly TableRecord[],
collapsedTurns: ReadonlySet<number>,
): TableRecord[] {
if (collapsedTurns.size === 0) return [...records]
const recordsByTurn = new Map<number, TableRecord[]>()
for (const record of records) {
if (record.turn === null) continue
@@ -550,15 +606,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string {
function collapseAssistantRecords(
records: readonly TableRecord[],
collapsedAssistants: ReadonlySet<number>,
collapsedAssistants: ReadonlySet<string>,
): TableRecord[] {
if (collapsedAssistants.size === 0) return [...records]
const out: TableRecord[] = []
for (let i = 0; i < records.length; i++) {
const record = records[i]
if (record === undefined) continue
out.push(record)
if (record.cell.kind !== 'message' || !collapsedAssistants.has(record.cell.index)) continue
if (
record.cell.kind !== 'message'
|| !collapsedAssistants.has(trajectoryRecordId(record.cell))
) continue
const calls: TableRecord[] = []
for (let j = i + 1; j < records.length; j++) {
const candidate = records[j]
@@ -1335,7 +1393,7 @@ function RequestTiming({
<dt>Started</dt>
<StartedAtValue timestamp={anchor?.cell.startedAt ?? null} />
</div>
<div><dt>Duration</dt><dd></dd></div>
<div><dt>Duration</dt><dd>{formatElapsedSeconds(null)}</dd></div>
</dl>
)
}
@@ -1519,7 +1577,12 @@ function OverviewSection({
<IconChevronRightOutline14 className={css.overviewTitleIcon} size={12} />
</button>
</h3>
<div className={css.overviewPreview}>{children}</div>
<div
className={`${css.overviewPreview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{children}
</div>
</section>
)
}
@@ -1533,11 +1596,17 @@ function OverviewSection({
export function TrajectoryTable({
requestNumbers: sessionRequestNumbers,
turns,
streamingCells = [],
timelineFocusIndexes = null,
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
recordSelection = null,
recordFocus = null,
historyLoading = false,
historyStartSeq,
hasOlderRecords = false,
onLoadOlder,
onClearSelection,
collapsedTurns,
onToggleTurn,
@@ -1546,7 +1615,7 @@ export function TrajectoryTable({
inspectCallId = null,
onInspectApplied,
}: TrajectoryTableProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRecordId, setSelectedRecordId] = useState<string | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
const [activeTab, setActiveTab] = useState<DetailTab>('overview')
const [thinkingExpanded, setThinkingExpanded] = useState(false)
@@ -1554,20 +1623,116 @@ export function TrajectoryTable({
const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
const appliedRecordFocus = useRef<TrajectoryTableProps['recordFocus']>(null)
const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const [tableScrollReady, setTableScrollReady] = useState(false)
const pendingScrollRecordId = useRef<string | null>(null)
const loadingOlder = useRef(false)
const [olderLoading, setOlderLoading] = useState(false)
const olderLoadAnchor = useRef<OlderLoadAnchor | null>(null)
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const streamingCellsByIndex = useMemo(
() => new Map(streamingCells.map(cell => [cell.index, cell])),
[streamingCells],
)
const currentRecord = useCallback((record: TableRecord): TableRecord => {
const cell = streamingCellsByIndex.get(record.cell.index)
return cell === undefined ? record : { ...record, cell }
}, [streamingCellsByIndex])
const selectedTemplate = useMemo(() => selectedRecordId === null
? undefined
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId),
[allRecords, selectedRecordId])
const selected = selectedTemplate === undefined
? undefined
: currentRecord(selectedTemplate)
const selectedIndex = selected?.cell.index ?? null
useEffect(() => {
onSelectedIndexChange?.(selectedIndex)
}, [onSelectedIndexChange, selectedIndex])
const allRecords = useMemo(() => flattenRecords(turns), [turns])
const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
const records = searchMatchIndexes === null
? collapseAssistantRecords(
collapseTurnRecords(allRecords, collapsedTurns),
collapsedAssistants,
)
: filterRecords(allRecords, searchMatchIndexes)
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
const selected = allRecords.find(record => record.cell.index === selectedIndex)
const requestNumbers = useMemo(
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
[allRecords, sessionRequestNumbers],
)
const records = useMemo(() => {
if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes)
const turnRecords = collapsedTurns.size === 0
? allRecords
: collapseTurnRecords(allRecords, collapsedTurns)
return collapsedAssistants.size === 0
? turnRecords
: collapseAssistantRecords(turnRecords, collapsedAssistants)
}, [allRecords, collapsedAssistants, collapsedTurns, searchMatchIndexes])
const projectedVirtualRows = useMemo(
() => groupTrajectoryVirtualRows(records),
[records],
)
const virtualRowStructure = useStableVirtualRowStructure(projectedVirtualRows)
const virtualizationEnabled = hasOlderRecords
|| records.length > VIRTUALIZATION_THRESHOLD
const estimateVirtualRowSize = useCallback(
(index: number) => virtualRowStructure[index]?.height ?? 30,
[virtualRowStructure],
)
const getVirtualRowKey = useCallback(
(index: number) => virtualRowStructure[index]?.key ?? index,
[virtualRowStructure],
)
const getTableScrollElement = useCallback(() => tablePaneRef.current, [])
const rowVirtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
count: virtualizationEnabled ? virtualRowStructure.length : 0,
enabled: virtualizationEnabled,
estimateSize: estimateVirtualRowSize,
getItemKey: getVirtualRowKey,
getScrollElement: getTableScrollElement,
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
anchorTo: 'end',
overscan: VIRTUAL_OVERSCAN_ROWS,
scrollEndThreshold: BOTTOM_FOLLOW_THRESHOLD_PX,
})
const virtualIndexByRecordId = useMemo(() => {
const indexes = new Map<string, number>()
for (const [virtualIndex, row] of projectedVirtualRows.entries()) {
for (const entry of row.entries) {
if (entry.record.collapsedSummary === undefined) {
indexes.set(trajectoryRecordId(entry.record.cell), virtualIndex)
}
}
}
return indexes
}, [projectedVirtualRows])
const virtualItems = virtualizationEnabled ? rowVirtualizer.getVirtualItems() : []
const virtualTop = virtualItems[0]?.start ?? 0
const virtualBottom = virtualItems.length === 0
? 0
: Math.max(0, rowVirtualizer.getTotalSize() - (virtualItems.at(-1)?.end ?? 0))
const renderedRecords = virtualizationEnabled
? virtualItems.flatMap((item) => {
const row = projectedVirtualRows[item.index]
if (row === undefined) return []
return row.entries.map((entry, entryIndex) => ({
record: currentRecord(entry.record),
position: entry.logicalIndex,
terminalRequestBoundary:
entry.record.cell.requestOnly === true
&& row.entries.at(-1)?.record.cell.requestOnly === true
&& entryIndex === row.entries.length - 1,
}))
})
: records.map((record, position) => ({
record: currentRecord(record),
position,
terminalRequestBoundary:
record.cell.requestOnly === true && position === records.length - 1,
}))
const requestBoundaryRuns = useMemo(
() => indexRequestBoundaryRuns(records),
[records],
)
const selectedPrompt = selected?.cell.kind === 'system'
? selected.cell.promptDetail
: undefined
@@ -1576,20 +1741,25 @@ export function TrajectoryTable({
: undefined
const promptSelected = selectedPrompt !== undefined
const selectedState = selected === undefined ? undefined : stateOf(selected)
const selectedRequestRecords = selectedRequest === null
const selectedRequestRecordTemplates = useMemo(() => selectedRequest === null
? []
: allRecords.filter(record =>
record.turn === selectedRequest.turn
&& record.section === selectedRequest.section
&& record.group === selectedRequest.group,
)
), [allRecords, selectedRequest])
const selectedRequestRecords = selectedRequestRecordTemplates.map(currentRecord)
const selectedRequestAssistant = selectedRequestRecords.find(
record => record.cell.kind === 'message',
)
const selectedRequestAnchor = selectedRequestAssistant ?? selectedRequestRecords[0]
const selectedRequestNumber = selectedRequest === null
? undefined
: requestNumbers.get(requestKey(selectedRequest.turn, selectedRequest.group))
const selectedRequestInfo = selectedRequest === null
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedRequest.number)
: sessionRequestNumbers?.find(request => selectedRequest.seq === undefined
? request.turn === selectedRequest.turn && request.group === selectedRequest.group
: request.seq === selectedRequest.seq)
const selectedRequestState: RecordState | undefined = selectedRequest === null
? undefined
: selectedRequestInfo?.status
@@ -1605,9 +1775,12 @@ export function TrajectoryTable({
const selectedRequestSubtoolCalls = selectedRequestRecords.filter(
record => record.cell.kind === 'subtool',
).length
const selectedRequestResult = selectedRequestInfo?.resultSeq === undefined
const selectedRequestResultTemplate = selectedRequestInfo?.resultSeq === undefined
? selectedRequestAssistant
: allRecords.find(record => record.cell.sourceSeq === selectedRequestInfo.resultSeq)
const selectedRequestResult = selectedRequestResultTemplate === undefined
? undefined
: currentRecord(selectedRequestResultTemplate)
const selectedRequestUsage = selectedRequestInfo?.usage ?? (
selectedRequestAssistant === undefined
? undefined
@@ -1633,7 +1806,9 @@ export function TrajectoryTable({
selectedRequestInfo?.cumulativeUsage ?? selectedRequestUsage
const selectedRequestOptions = selectedRequestInfo?.requestConfig
const activeTurn = selectedRequest === null ? selected?.turn : selectedRequest.turn
const activeSection = selectedRequest === null ? selected?.section : selectedRequest.section
const activeSection = selectedRequest === null
? selected?.section
: selectedRequestRecords[0]?.section
const selectedTabs = selectedRequest !== null
? REQUEST_TABS.filter(tab => tab.id !== 'options' || selectedRequestOptions !== undefined)
: selected === undefined ? [] : detailTabs(selected)
@@ -1645,13 +1820,17 @@ export function TrajectoryTable({
const selectedAssistantRequest = selected?.cell.kind === 'message'
? requestNumbers.get(requestKey(selected.turn, selected.group))
: undefined
const selectedAssistantRequestInfo = selectedAssistantRequest === undefined
? undefined
: sessionRequestNumbers?.find(request => request.number === selectedAssistantRequest)
const selectedAssistantRequestTarget: SelectedRequest | undefined =
selected !== undefined && selectedAssistantRequest !== undefined
? {
turn: selected.turn,
section: selected.section,
number: selectedAssistantRequest,
group: selected.group,
...(selectedAssistantRequestInfo?.seq === undefined
? {}
: { seq: selectedAssistantRequestInfo.seq }),
}
: undefined
const hasSelectedHierarchy = selectedAssistantRequestTarget !== undefined
@@ -1670,7 +1849,7 @@ export function TrajectoryTable({
}
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(null)
}
@@ -1683,7 +1862,7 @@ export function TrajectoryTable({
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
setSelectedRequest(null)
setSelectedIndex(index)
setSelectedRecordId(record === undefined ? null : trajectoryRecordId(record.cell))
if (record === undefined) return
const tabs = detailTabs(record)
const available = new Set(tabs.map(tab => tab.id))
@@ -1697,13 +1876,25 @@ export function TrajectoryTable({
) return
appliedRecordSelection.current = recordSelection
selectRecord(recordSelection.index)
}, [recordSelection, selectRecord])
const record = allRecords.find(candidate => candidate.cell.index === recordSelection.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordSelection, selectRecord])
useEffect(() => {
if (recordFocus === null || appliedRecordFocus.current === recordFocus) return
appliedRecordFocus.current = recordFocus
const record = allRecords.find(candidate => candidate.cell.index === recordFocus.index)
pendingScrollRecordId.current = record === undefined
? null
: trajectoryRecordId(record.cell)
}, [allRecords, recordFocus])
const selectRequest = (
request: SelectedRequest,
tab: 'overview' | 'timing' = 'overview',
) => {
setSelectedIndex(null)
setSelectedRecordId(null)
setSelectedRequest(request)
activateTab(tab)
}
@@ -1716,12 +1907,13 @@ export function TrajectoryTable({
const candidate = allRecords[i]
if (candidate === undefined || candidate.turn !== target.turn) break
if (candidate.cell.kind !== 'message') continue
if (collapsedAssistants.has(candidate.cell.index)) onToggleAssistant(candidate.cell.index)
const assistantId = trajectoryRecordId(candidate.cell)
if (collapsedAssistants.has(assistantId)) onToggleAssistant(assistantId)
break
}
}
setSelectedRequest(null)
setSelectedIndex(target.cell.index)
setSelectedRecordId(trajectoryRecordId(target.cell))
activateTab('overview')
}
@@ -1734,11 +1926,6 @@ export function TrajectoryTable({
// open its summary, and remember the row to scroll once the un-collapsed
// ledger has rendered. Not-found leaves the request pending (`turns` in the
// deps retries as history pages in); the ack clears the store field.
const rootRef = useRef<HTMLDivElement>(null)
const tablePaneRef = useRef<HTMLDivElement>(null)
const followsTableTail = useRef(false)
const tableScrollInitialized = useRef(false)
const pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
useEffect(() => {
@@ -1746,61 +1933,213 @@ export function TrajectoryTable({
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
if (target === undefined) return
openRecordSummaryRef.current(target)
pendingScrollIndex.current = target.cell.index
pendingScrollRecordId.current = trajectoryRecordId(target.cell)
onInspectApplied?.()
}, [inspectCallId, turns, onInspectApplied])
useEffect(() => {
const index = pendingScrollIndex.current
if (index === null) return
const row = rootRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row === undefined || row === null) return
pendingScrollIndex.current = null
const id = pendingScrollRecordId.current
if (id === null) return
const position = records.findIndex(record =>
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
if (position === -1) return
if (virtualizationEnabled) {
const virtualIndex = virtualIndexByRecordId.get(id)
if (virtualIndex === undefined) return
pendingScrollRecordId.current = null
followsTableTail.current = false
rowVirtualizer.scrollToIndex(virtualIndex, { behavior: 'smooth', align: 'center' })
return
}
pendingScrollRecordId.current = null
followsTableTail.current = false
const recordIndex = records[position]?.cell.index
const row = recordIndex === undefined
? null
: rootRef.current?.querySelector<HTMLElement>(`tr[data-record-index="${recordIndex}"]`)
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (typeof row.scrollIntoView === 'function') {
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
}, [records, rowVirtualizer, virtualIndexByRecordId, virtualizationEnabled])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const focusedPositions = records.flatMap((record, position) =>
record.collapsedSummary === undefined
&& record.cell.requestOnly !== true
&& timelineFocusIndexes.has(record.cell.index)
? [position]
: [])
const first = focusedPositions.at(0)
const last = focusedPositions.at(-1)
if (first === undefined || last === undefined) return
if (!virtualizationEnabled) {
const ledger = rootRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const firstRow = focusedRows.at(0)
const lastRow = focusedRows.at(-1)
if (firstRow === undefined || lastRow === undefined) return
const focusHeight =
lastRow.getBoundingClientRect().bottom - firstRow.getBoundingClientRect().top
const target = focusHeight > ledger.clientHeight
? firstRow
: focusedRows[Math.floor((focusedRows.length - 1) / 2)]
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (target !== undefined && typeof target.scrollIntoView === 'function') {
followsTableTail.current = false
target.scrollIntoView({
behavior: 'smooth',
block: focusHeight > ledger.clientHeight ? 'start' : 'center',
})
}
return
}
const focusedVirtualIndexes = [...new Set(focusedPositions.flatMap((position) => {
const record = records[position]
if (record === undefined) return []
const virtualIndex = virtualIndexByRecordId.get(trajectoryRecordId(record.cell))
return virtualIndex === undefined ? [] : [virtualIndex]
}))].sort((left, right) => left - right)
const firstVirtual = focusedVirtualIndexes.at(0)
const lastVirtual = focusedVirtualIndexes.at(-1)
if (firstVirtual === undefined || lastVirtual === undefined) return
const paneHeight = tablePaneRef.current?.clientHeight ?? 0
const focusHeight = projectedVirtualRows
.slice(firstVirtual, lastVirtual + 1)
.reduce((height, row) => height + row.height, 0)
followsTableTail.current = false
rowVirtualizer.scrollToIndex(
focusHeight > paneHeight
? firstVirtual
: focusedVirtualIndexes[Math.floor((focusedVirtualIndexes.length - 1) / 2)]
?? firstVirtual,
{
behavior: 'smooth',
align: focusHeight > paneHeight ? 'start' : 'center',
},
)
}, [
projectedVirtualRows,
records,
rowVirtualizer,
timelineFocusIndexes,
virtualIndexByRecordId,
virtualizationEnabled,
])
const requestOlder = useCallback((pane: HTMLDivElement) => {
if (
!hasOlderRecords
|| onLoadOlder === undefined
|| loadingOlder.current
|| pane.scrollTop > OLDER_LOAD_THRESHOLD_PX
) return
loadingOlder.current = true
setOlderLoading(true)
olderLoadAnchor.current = {
historyStartSeq,
scrollHeight: pane.scrollHeight,
scrollTop: pane.scrollTop,
}
void onLoadOlder().then((advanced) => {
if (!advanced) olderLoadAnchor.current = null
}).finally(() => {
loadingOlder.current = false
setOlderLoading(false)
})
}, [hasOlderRecords, historyStartSeq, onLoadOlder])
useLayoutEffect(() => {
const pane = tablePaneRef.current
if (pane === null) return
if (!tableScrollInitialized.current) {
tableScrollInitialized.current = true
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
const anchor = olderLoadAnchor.current
if (anchor !== null && anchor.historyStartSeq !== historyStartSeq) {
if (!virtualizationEnabled) {
pane.scrollTop = anchor.scrollTop + pane.scrollHeight - anchor.scrollHeight
}
olderLoadAnchor.current = null
followsTableTail.current = false
return
}
if (followsTableTail.current) pane.scrollTop = pane.scrollHeight
}, [turns])
if (!tableScrollInitialized.current) {
if (historyLoading) return
tableScrollInitialized.current = true
followsTableTail.current = true
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
setTableScrollReady(true)
return
}
if (!followsTableTail.current) return
if (virtualizationEnabled) rowVirtualizer.scrollToEnd({ behavior: 'auto' })
else pane.scrollTop = pane.scrollHeight
}, [
historyLoading,
historyStartSeq,
rowVirtualizer,
virtualRowStructure,
virtualizationEnabled,
])
const loadingLabel = olderLoading
? 'Loading earlier history…'
: 'Loading trajectory…'
const showLoading = historyLoading || olderLoading || !tableScrollReady
return (
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
ref={tablePaneRef}
className={css.tablePane}
data-trajectory-scroll=""
onScroll={(event) => {
const pane = event.currentTarget
followsTableTail.current =
pane.scrollHeight - pane.clientHeight - pane.scrollTop
<= BOTTOM_FOLLOW_THRESHOLD_PX
requestOlder(pane)
}}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}
>
<table className={css.table}>
{showLoading && (
<div className={css.historyLoading} role="status" aria-live="polite">
<span className={css.historyLoadingBar}>
<span className={css.historyLoadingSpinner} aria-hidden="true" />
{loadingLabel}
</span>
</div>
)}
<table
className={css.table}
data-scroll-ready={tableScrollReady || undefined}
aria-rowcount={records.length}
>
<colgroup>
<col className={css.eventColumn} />
<col className={css.contentColumn} />
</colgroup>
<tbody>
{records.map((record) => {
{virtualTop > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="top" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualTop}px`,
} as VirtualSpacerStyle}
/>
</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 = toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const listDisplayText = toolCallOnly
? '(tool call only)'
: toolCallText === undefined
? displayText
: [toolCallText.name, toolCallText.args].filter(Boolean).join(' ')
const isCollapsedSummary = record.collapsedSummary !== undefined
const isRequestOnly = record.cell.requestOnly === true
const isInitialSystem = record.cell.kind === 'system'
@@ -1824,15 +2163,15 @@ export function TrajectoryTable({
: `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}`
const requestSelected = request !== undefined
&& selectedRequest?.turn === record.turn
&& selectedRequest.section === record.section
&& selectedRequest.number === request
&& selectedRequest.group === record.group
const sectionActive = record.turn === null
? activeSection === record.section
: activeTurn === record.turn
return (
<tr
key={`${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`}
key={trajectoryVirtualRecordKey(record)}
tabIndex={isRequestOnly ? -1 : 0}
aria-rowindex={position + 1}
aria-label={isCollapsedSummary
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
: isRequestOnly
@@ -1840,10 +2179,13 @@ export function TrajectoryTable({
: `${request === undefined ? '' : `Request ${request}, `}${KIND_LABEL[record.cell.kind]}, ${listDisplayText || 'no content'}`}
aria-selected={!isCollapsedSummary && !isRequestOnly && selectedIndex === record.cell.index}
data-kind={record.cell.kind}
data-trajectory-row-key={trajectoryVirtualRecordKey(record)}
data-virtual-position={virtualizationEnabled ? position : undefined}
data-record-index={!isCollapsedSummary && !isRequestOnly
? record.cell.index
: undefined}
data-request-only={isRequestOnly || undefined}
data-terminal-request-boundary={terminalRequestBoundary || undefined}
data-group-start={record.groupStart || undefined}
data-turn-start={record.turnStart || undefined}
data-error={record.cell.isError || undefined}
@@ -1860,7 +2202,7 @@ export function TrajectoryTable({
? () => {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
}
: () => { selectRecord(record.cell.index) }}
onDoubleClick={(event) => {
@@ -1875,7 +2217,7 @@ export function TrajectoryTable({
&& assistantToolCalls(allRecords, record.cell.index).length > 0
) {
event.preventDefault()
onToggleAssistant(record.cell.index)
onToggleAssistant(trajectoryRecordId(record.cell))
return
}
if (!record.turnStart) return
@@ -1894,7 +2236,7 @@ export function TrajectoryTable({
if (isCollapsedSummary) {
if (record.collapsedSummaryKind === 'turn' && record.turn !== null) {
onToggleTurn(record.turn)
} else onToggleAssistant(record.cell.index)
} else onToggleAssistant(trajectoryRecordId(record.cell))
return
}
selectRecord(record.cell.index)
@@ -1917,9 +2259,8 @@ export function TrajectoryTable({
event.stopPropagation()
selectRequest({
turn: record.turn,
section: record.section,
number: request,
group: record.group,
...(requestInfo?.seq === undefined ? {} : { seq: requestInfo.seq }),
})
}}
onDoubleClick={(event) => { event.stopPropagation() }}
@@ -2013,8 +2354,8 @@ export function TrajectoryTable({
: `${listDisplayText}${record.cell.result}`}
>
<span className={record.cell.result === undefined ? undefined : css.resultRequest}>
{isToolCallOnly(record.cell)
? null
{toolCallOnly
? <span className={css.toolCallOnly}>(tool call only)</span>
: toolCallText === undefined
? listDisplayText || '—'
: (
@@ -2047,6 +2388,16 @@ export function TrajectoryTable({
</tr>
)
})}
{virtualBottom > 0 && (
<tr className={css.virtualSpacer} data-virtual-spacer="bottom" aria-hidden="true">
<td
colSpan={2}
style={{
'--trajectory-virtual-spacer-height': `${virtualBottom}px`,
} as VirtualSpacerStyle}
/>
</tr>
)}
</tbody>
</table>
</div>
@@ -2141,7 +2492,7 @@ export function TrajectoryTable({
<>
<span className={css.requestDetailsDot} aria-hidden="true" />
<span className={css.requestDetailsName}>
Request #{selectedRequest.number}
Request #{selectedRequestNumber ?? '—'}
</span>
<span className={css.detailsLocation}>
{selectedRequestInfo?.purpose === 'compaction'
@@ -2220,7 +2571,10 @@ export function TrajectoryTable({
&& selectedRequestState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedRequestState === 'error' ? css.error : undefined}>
@@ -2371,7 +2725,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<div>
<dt>Status</dt>
<dd className={selectedState === 'error' ? css.error : undefined}>
@@ -2388,7 +2745,10 @@ export function TrajectoryTable({
</div>
</dl>
{selected.cell.outputDetail !== undefined && (
<div className={css.compactedSummary}>
<div
className={`${css.compactedSummary} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
<MarkdownRecordContent
record={selected}
rendered
@@ -2406,7 +2766,10 @@ export function TrajectoryTable({
&& selectedState !== undefined
&& activeTab === 'overview' && (
<>
<dl className={css.overview}>
<dl
className={`${css.overview} ${css.summaryScrollRegion}`}
data-summary-scroll-region=""
>
{selected.cell.messageSource !== undefined && (
<div>
<dt>Origin</dt>
@@ -2441,7 +2804,7 @@ export function TrajectoryTable({
selectRequest(selectedAssistantRequestTarget)
}}
>
<span>Request #{selectedAssistantRequestTarget.number}</span>
<span>Request #{selectedAssistantRequest ?? '—'}</span>
<IconChevronRightOutline14
className={css.overviewHierarchyJumpIconTight}
size={11}

View File

@@ -61,6 +61,46 @@
cursor: grabbing;
}
.earlierHistory {
position: absolute;
z-index: 5;
top: 0;
bottom: 0;
left: 0;
display: flex;
width: 28px;
align-items: center;
justify-content: flex-start;
appearance: none;
box-sizing: border-box;
padding-left: 3px;
border: 0;
outline: none;
background: linear-gradient(
to right,
var(--dsw-alias-bg-layer-2) 0,
var(--dsw-alias-bg-layer-2) 38%,
transparent 100%
);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
line-height: 1;
opacity: 0.72;
cursor: pointer;
}
.earlierHistory:hover {
opacity: 1;
}
.earlierHistory[aria-disabled='true'] {
cursor: default;
}
.earlierHistory:focus-visible {
box-shadow: inset 0 0 0 1px var(--dsw-alias-border-l2);
}
.empty {
position: absolute;
top: 50%;

View File

@@ -132,6 +132,10 @@ export interface TrajectoryTimelineProps {
turns: readonly TrajectoryTurnModel[]
mode: TrajectoryTimelineMode
range: TrajectoryTimeRange | null
/** Whether the loaded timeline omits an earlier history prefix. */
hasEarlierRecords?: boolean
/** Load one earlier history page from the truncation control. */
onLoadEarlier?: () => Promise<boolean>
selectedIndex?: number | null
/** Record indexes matching the active ledger search, or null without a query. */
searchMatchIndexes?: ReadonlySet<number> | null
@@ -191,11 +195,49 @@ function LaneLabels() {
)
}
function EarlierHistoryBoundary({
loading,
onHover,
onLoad,
}: {
loading: boolean
onHover: () => void
onLoad: (() => void) | undefined
}) {
return (
<Tooltip
label={loading ? 'Loading earlier history…' : 'Click to load earlier history'}
side="right"
delayMs={TIMELINE_TOOLTIP_DELAY_MS}
>
<button
type="button"
className={css.earlierHistory}
data-earlier-history
data-loading={loading || undefined}
aria-label={loading ? 'Loading earlier history' : 'Load earlier history'}
aria-disabled={loading || onLoad === undefined}
onClick={onLoad}
onPointerEnter={(event) => {
event.stopPropagation()
onHover()
}}
onPointerMove={(event) => { event.stopPropagation() }}
onPointerDown={(event) => { event.stopPropagation() }}
>
</button>
</Tooltip>
)
}
/** Overview renderer with drag ranges, click-sized focus, and Escape reset. */
export const TrajectoryTimeline = memo(function TrajectoryTimeline({
turns,
mode,
range,
hasEarlierRecords = false,
onLoadEarlier,
selectedIndex = null,
searchMatchIndexes = null,
onRangeChange,
@@ -222,6 +264,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const [panning, setPanning] = useState(false)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
const [animateViewport, setAnimateViewport] = useState(false)
@@ -278,6 +321,15 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
)
const domainDuration = viewport === null ? fullDuration : viewportDuration
const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
const showsEarlierBoundary = hasEarlierRecords
&& model !== null
&& domainStart === model.start
const loadEarlier = onLoadEarlier === undefined || loadingEarlier
? undefined
: () => {
setLoadingEarlier(true)
void onLoadEarlier().finally(() => { setLoadingEarlier(false) })
}
const projectedDomainStyle = model === null
? undefined
: {
@@ -333,6 +385,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
<LaneLabels />
<div className={css.track}>
<span className={css.empty}>No timing data</span>
{hasEarlierRecords && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
</div>
</div>
</section>
@@ -541,6 +600,13 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
event.preventDefault()
}}
>
{showsEarlierBoundary && (
<EarlierHistoryBoundary
loading={loadingEarlier}
onHover={() => { setHover(null) }}
onLoad={loadEarlier}
/>
)}
{hover !== null && hover.recordIndex === null && draft === null && (
<div
className={css.hoverLine}
@@ -609,7 +675,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
.map((span) => {
const left = (span.start - model.start) / fullDuration
const width = (span.end - span.start) / fullDuration
const widthPercent = Math.max(width * 100, 0.35)
const widthPercent = width * 100
const detail = detailByIndex.get(span.index)
const ttftMs = detail?.ttftMs
const decodingMs = detail?.decodingMs
@@ -646,7 +712,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
style={{
'--trajectory-span-left': `${left * 100}%`,
'--trajectory-span-width': `${widthPercent}%`,
'--trajectory-span-gap': `clamp(0.25px, ${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-gap': `min(${widthPercent * 0.08}%, 1px)`,
'--trajectory-span-lane': span.lane,
...(ttftFraction === null
? {}

View File

@@ -4,7 +4,7 @@ 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 {
AssistantMessageNode, ConversationContext,
AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot,
SessionHistoryFace, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
@@ -17,15 +17,51 @@ import {
} from './TrajectoryTable.tsx'
import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
import { deriveTrajectoryLayout } from './layout.ts'
import {
appendTrajectoryPartialLayout, deriveTrajectoryLayout,
type TrajectoryTurnModel,
} from './layout.ts'
import {
trajectoryTimelineFocusIndexes,
type TrajectoryTimelineMode,
type TrajectoryTimeRange,
} from './timeline.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set()
const EMPTY_TURN_IDS: ReadonlySet<number> = new Set()
const EMPTY_RECORD_IDS: ReadonlySet<string> = new Set()
function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number {
let last = 0
for (const turn of turns) {
for (const group of turn.groups) {
for (const cell of group.cells) last = Math.max(last, cell.index)
}
}
return last
}
function timelineBlock(block: AssistantBlock): AssistantBlock {
switch (block.kind) {
case 'text': return { kind: 'text', text: '' }
case 'reasoning': return { kind: 'reasoning', text: '' }
case 'tool-call': return {
kind: 'tool-call',
callId: block.callId,
name: block.name,
argsRaw: '',
}
case 'other': return { kind: 'other', block: null }
}
}
function partialStructureSignature(partial: ConversationSnapshot['partial']): string {
if (partial === null) return ''
return partial.blocks.map(block => block.kind === 'tool-call'
? `${block.kind}:${block.callId}:${block.name}`
: block.kind).join('\u0000')
}
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
@@ -33,7 +69,8 @@ export interface TrajectoryViewInjected {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadAllHistory: (signal: AbortSignal) => Promise<void>
loadHistoryTail: (signal: AbortSignal) => Promise<void>
loadOlderHistory: (signal: AbortSignal) => Promise<boolean>
setActualDuration: (actualDuration: boolean) => void
}
@@ -137,14 +174,23 @@ function searchMatches(
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({
useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration,
inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
useState<ReadonlySet<number>>(EMPTY_IDS)
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
const [timelineSelection, setTimelineSelection] = useState<{
branchId: number
branchKey: string
range: TrajectoryTimeRange
} | null>(null)
const actualDuration = useDuration(value => value)
@@ -154,26 +200,36 @@ export function TrajectoryView({
const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
readonly index: number
} | null>(null)
const ledgerRef = useRef<HTMLDivElement>(null)
const [timelineRecordFocus, setTimelineRecordFocus] = useState<{
readonly index: number
} | null>(null)
const inspection = useHistory(snapshot => snapshot.inspection)
const historyLoading = useHistory(snapshot =>
snapshot.state === 'cold' || snapshot.state === 'loading')
const hasOlderHistory = useHistory(snapshot => snapshot.hasMore)
const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq)
const nodes = inspection.eventNodes
const partial = inspection.partial
const runningCalls = inspection.runningCalls
const codeDispatches = inspection.codeDispatches
const loadAllHistoryRef = useRef(loadAllHistory)
loadAllHistoryRef.current = loadAllHistory
const loadHistoryTailRef = useRef(loadHistoryTail)
loadHistoryTailRef.current = loadHistoryTail
const historyControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadAllHistoryRef.current(controller.signal)
historyControllerRef.current = controller
void loadHistoryTailRef.current(controller.signal)
return () => { controller.abort() }
}, [])
const requests = inspection.requests
const callSchemas = inspection.callSchemas
const historyContexts = inspection.contexts
const interruptedNodes = inspection.interruptedNodes
const contexts = useMemo<readonly ConversationContext[]>(
() => inspection.contexts.length === 0
() => historyContexts.length === 0
? [{ id: 0, nodes }]
: inspection.contexts,
[inspection, nodes],
: historyContexts,
[historyContexts, nodes],
)
const branches = useMemo(
() => deriveTrajectoryContextBranches(contexts),
@@ -183,18 +239,18 @@ export function TrajectoryView({
if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty')
const selectedNodes = useMemo(() => {
const selected = new Map(currentBranch.nodes.map(node => [node.seq, node]))
for (const node of inspection.interruptedNodes) {
for (const node of interruptedNodes) {
selected.set(node.seq, node)
}
return [...selected.values()].sort((left, right) => left.seq - right.seq)
}, [currentBranch, inspection])
}, [currentBranch.nodes, interruptedNodes])
const selectedRequests = useMemo(
() => requests.filter(request =>
trajectoryBranchContainsRequest(currentBranch, request),
),
[currentBranch, requests],
)
const globalRequestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const requestNumbers = useMemo<readonly TrajectoryRequestNumber[]>(() => {
const assistantsByStep = new Map<string, AssistantMessageNode>()
for (const context of contexts) {
for (const node of context.nodes) {
@@ -295,63 +351,74 @@ export function TrajectoryView({
})
}
if (partial !== null && partial.step > 0) {
const key = `${partial.turn}\u0000${partial.step}`
const recorded = numbered.some(request =>
`${request.turn}\u0000${request.step}` === key,
)
if (!recorded) {
numbered.push({
turn: partial.turn,
step: partial.step,
group: `Step ${partial.step}`,
number: orderedRequests.length + 1,
...(currentBranch.latest.prompt?.config.provider === undefined
? {}
: { provider: currentBranch.latest.prompt.config.provider }),
...(currentBranch.latest.prompt?.config.model === undefined
? {}
: { model: currentBranch.latest.prompt.config.model }),
...(currentBranch.latest.prompt?.config === undefined
? {}
: { requestConfig: currentBranch.latest.prompt.config }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
})
}
}
return numbered
}, [
contexts, currentBranch.latest.prompt, nodes, partial, requests,
contexts, nodes, requests,
])
const requestNumbers = globalRequestNumbers
const turns = useMemo(
() => deriveTrajectoryLayout({
const partialTurn = partial?.turn ?? null
const partialStep = partial?.step ?? null
const finalized = useMemo(() => {
const turns = deriveTrajectoryLayout({
nodes: selectedNodes,
partial,
partial: partialTurn === null || partialStep === null
? null
: { turn: partialTurn, step: partialStep, blocks: [] },
runningCalls,
requests: selectedRequests,
callSchemas,
codeDispatches,
}),
[
selectedNodes, partial, runningCalls, selectedRequests, callSchemas, codeDispatches,
],
})
return { turns, lastIndex: lastCellIndex(turns) }
}, [
selectedNodes, partialTurn, partialStep,
runningCalls, selectedRequests, callSchemas, codeDispatches,
])
const timelinePartialSignature = partialStructureSignature(partial)
const timelinePartial = useMemo<ConversationSnapshot['partial']>(() => partial === null
? null
: {
turn: partial.turn,
step: partial.step,
blocks: partial.blocks.map(block => timelineBlock(block)),
},
[partialStep, partialTurn, timelinePartialSignature])
const timelineTurns = useMemo(
() => appendTrajectoryPartialLayout(finalized.turns, timelinePartial, finalized.lastIndex),
[finalized, timelinePartial],
)
const timelineMode: TrajectoryTimelineMode = actualDuration
? actualTime ? 'actual' : 'duration'
: actualTime ? 'time' : 'sequence'
const searchMatchIndexes = useMemo(
() => searchMatches(turns, searchQuery),
[searchQuery, turns],
const finalizedSearchMatches = useMemo(
() => searchMatches(finalized.turns, searchQuery),
[finalized, searchQuery],
)
const timelineRange = timelineSelection?.branchId === currentBranch.id
const partialSearchTurns = useMemo(
() => appendTrajectoryPartialLayout([], partial, finalized.lastIndex),
[finalized.lastIndex, partial],
)
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 timelineRange = timelineSelection?.branchKey === currentBranch.key
? timelineSelection.range
: null
const timelineFocusIndexes = useMemo(
() => timelineRange === null
? null
: trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
[timelineMode, timelineRange, turns],
: trajectoryTimelineFocusIndexes(timelineTurns, timelineRange, timelineMode),
[timelineMode, timelineRange, timelineTurns],
)
const handleRecordSelect = useCallback((index: number) => {
if (
@@ -361,31 +428,22 @@ export function TrajectoryView({
setTimelineSelection(null)
}
}, [timelineFocusIndexes])
useEffect(() => {
if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
const ledger = ledgerRef.current
if (ledger === null) return
const focusedRows = [
...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
]
const first = focusedRows.at(0)
const last = focusedRows.at(-1)
if (first === undefined || last === undefined) return
const focusHeight =
last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
if (focusHeight > ledger.clientHeight) {
if (typeof first.scrollIntoView === 'function') {
first.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
return
}
const middle = focusedRows[Math.floor((focusedRows.length - 1) / 2)]
if (middle !== undefined && typeof middle.scrollIntoView === 'function') {
middle.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [timelineFocusIndexes])
const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => {
setTimelineSelection(range === null ? null : {
branchKey: currentBranch.key,
range,
})
}, [currentBranch.key])
const handleTimelineRecordSelect = useCallback((index: number) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
}, [])
const handleTimelineRecordFocus = useCallback((index: number) => {
setTimelineRecordFocus({ index })
}, [])
const collapsibleTurnIds = useMemo(
() => turns
() => timelineTurns
.filter(turn =>
turn.turn !== null
&&
@@ -396,23 +454,25 @@ export function TrajectoryView({
0,
) > 1)
.flatMap(turn => turn.turn === null ? [] : [turn.turn]),
[turns],
[timelineTurns],
)
const allTurnsCollapsed = collapsibleTurnIds.length > 0
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
const collapsibleAssistantIds = useMemo(() => {
const ids: number[] = []
for (const turn of turns) {
const ids: string[] = []
for (const turn of timelineTurns) {
const cells = turn.groups.flatMap(group => group.cells)
for (let i = 0; i < cells.length; i++) {
const cell = cells[i]
if (cell?.kind !== 'message') continue
const next = cells[i + 1]
if (next?.kind === 'tool' || next?.kind === 'subtool') ids.push(cell.index)
if (next?.kind === 'tool' || next?.kind === 'subtool') {
ids.push(trajectoryRecordId(cell))
}
}
}
return ids
}, [turns])
}, [timelineTurns])
const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
&& collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
@@ -437,11 +497,11 @@ export function TrajectoryView({
})
}
const toggleAssistant = (index: number) => {
const toggleAssistant = (id: string) => {
setCollapsedAssistants((current) => {
const collapsed = new Set(current)
if (collapsed.has(index)) collapsed.delete(index)
else collapsed.add(index)
if (collapsed.has(id)) collapsed.delete(id)
else collapsed.add(id)
return collapsed
})
}
@@ -458,6 +518,13 @@ export function TrajectoryView({
})
}
const loadEarlierHistory = useCallback(() => {
const signal = historyControllerRef.current?.signal
return signal?.aborted === false
? loadOlderHistory(signal)
: Promise.resolve(false)
}, [loadOlderHistory])
return (
<div className={css.root} data-conversation-composer-overlay="">
<TrajectoryToolbar
@@ -479,45 +546,33 @@ export function TrajectoryView({
onSearchQueryChange={setSearchQuery}
/>
<TrajectoryTimeline
turns={turns}
turns={timelineTurns}
mode={timelineMode}
range={timelineRange}
hasEarlierRecords={hasOlderHistory}
onLoadEarlier={loadEarlierHistory}
selectedIndex={selectedTimelineIndex}
searchMatchIndexes={searchMatchIndexes}
onRangeChange={(range) => {
setTimelineSelection(range === null ? null : {
branchId: currentBranch.id,
range,
})
}}
onRecordSelect={(index) => {
setTimelineSelection(null)
setTimelineRecordSelection({ index })
setSelectedTimelineIndex(index)
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRecordFocus={(index) => {
const row = ledgerRef.current
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}}
onRangeChange={handleTimelineRangeChange}
onRecordSelect={handleTimelineRecordSelect}
onRecordFocus={handleTimelineRecordFocus}
/>
<div ref={ledgerRef} className={css.ledger}>
<div className={css.ledger}>
<TrajectoryTable
key={currentBranch.id}
key={currentBranch.key}
requestNumbers={requestNumbers}
turns={turns}
turns={timelineTurns}
streamingCells={streamingCells}
timelineFocusIndexes={timelineFocusIndexes}
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
recordSelection={timelineRecordSelection}
recordFocus={timelineRecordFocus}
historyLoading={historyLoading}
historyStartSeq={historyBaseSeq}
hasOlderRecords={hasOlderHistory}
onLoadOlder={loadEarlierHistory}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}

View File

@@ -7,6 +7,8 @@ import type {
/** One continuous context branch; compactions stay inline while rewinds start a successor branch. */
export interface TrajectoryContextBranch {
id: number
/** Identity stable when older context generations are prepended. */
key: string
contexts: readonly ConversationContext[]
latest: ConversationContext
nodes: readonly ConversationNode[]
@@ -18,6 +20,7 @@ export interface TrajectoryContextBranch {
interface MutableBranch {
id: number
key: string
contexts: ConversationContext[]
latest: ConversationContext
nodes: Map<number, ConversationNode>
@@ -63,6 +66,9 @@ export function deriveTrajectoryContextBranches(
)
mutable.push({
id: context.id,
key: context.origin === 'rewind' && context.originSeq !== undefined
? `rewind:${context.originSeq}`
: 'root',
contexts: [context],
latest: context,
nodes: new Map(
@@ -84,6 +90,7 @@ export function deriveTrajectoryContextBranches(
}
return mutable.map(branch => ({
id: branch.id,
key: branch.key,
contexts: branch.contexts,
latest: branch.latest,
nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq),

View File

@@ -35,7 +35,8 @@ export function apply(ctx: Context): void {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history, duration },
loadAllHistory: signal => history.loadAll(signal),
loadHistoryTail: signal => history.loadTail(signal),
loadOlderHistory: signal => history.loadOlder(signal),
setActualDuration: (value) => { duration.set(value) },
}
},

View File

@@ -17,6 +17,7 @@ import type {
TrajectoryCellProps,
TrajectorySourceBlock,
} from './trajectory-record.ts'
import { formatElapsedSeconds } from './trajectory-record.ts'
/** One Message or Step group inside a turn. */
export interface TrajectoryGroupModel {
@@ -473,6 +474,67 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T
].sort((left, right) => firstCellIndex(left) - firstCellIndex(right))
}
/**
* Append the changing in-flight assistant cells to a stable finalized layout.
* @param turns - Finalized layout derived with an empty-block partial anchor.
* @param partial - Current in-flight assistant projection.
* @param lastIndex - Highest cell index in the finalized layout.
* @returns The original layout without a partial, otherwise a layout sharing every unaffected turn.
*/
export function appendTrajectoryPartialLayout(
turns: readonly TrajectoryTurnModel[],
partial: ConversationSnapshot['partial'],
lastIndex: number,
): readonly TrajectoryTurnModel[] {
if (partial === null) return turns
const partialTurn = deriveTrajectoryLayout({
nodes: [],
partial,
runningCalls: [],
codeDispatches: new Map(),
}).at(0)
if (partialTurn === undefined) return turns
const streamed: TrajectoryTurnModel = {
...partialTurn,
groups: partialTurn.groups.map(group => ({
...group,
cells: group.cells.map(cell => ({ ...cell, index: cell.index + lastIndex })),
})),
}
const turnIndex = turns.findIndex(turn => turn.turn === streamed.turn)
if (turnIndex === -1) return [...turns, streamed]
const current = turns[turnIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (current === undefined) return turns
const groups = [...current.groups]
for (const streamedGroup of streamed.groups) {
const groupIndex = groups.findIndex(group => group.title === streamedGroup.title)
if (groupIndex === -1) {
groups.push(streamedGroup)
continue
}
const group = groups[groupIndex]
/* v8 ignore next -- findIndex proved the dense array position exists. */
if (group === undefined) continue
const streamedCallIds = new Set(
streamedGroup.cells.flatMap(cell => cell.callId === undefined ? [] : [cell.callId]),
)
groups[groupIndex] = {
...streamedGroup,
cells: [
...group.cells.filter(cell =>
cell.requestOnly !== true
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
),
...streamedGroup.cells,
],
}
}
const updated = [...turns]
updated[turnIndex] = { ...current, groups }
return updated
}
function attachToolSchema(
laid: LaidCell,
callSchemas: RequestInspectionSnapshot['callSchemas'] | undefined,
@@ -540,9 +602,7 @@ function groupDescription(laid: readonly LaidCell[]): string | undefined {
function formatGroupDuration(seconds: number): string | undefined {
if (!Number.isFinite(seconds)) return undefined
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
return formatElapsedSeconds(seconds)
}
/** Own-duration seconds from two epoch-ms stamps; null when either is unusable. */
@@ -564,6 +624,7 @@ function expandAssistant(
callStarts: ReadonlyMap<string, number>,
opts?: { streaming?: boolean },
): LaidCell[] {
if (opts?.streaming === true && node.blocks.length === 0) return []
const out: LaidCell[] = []
let index = startIndex - 1
const usage = node.usage as UsageLike | undefined
@@ -583,6 +644,7 @@ function expandAssistant(
.join('\n\n')
const message: TrajectoryCellProps = {
index: ++index,
recordId: `assistant\u0000${node.turn}\u0000${node.step}`,
kind: 'message',
sourceSeq: node.seq,
text: messageText !== ''

View File

@@ -1,6 +1,7 @@
/** Operation-sequence and recorded-time projections for the trajectory overview. */
import type { TrajectoryTurnModel } from './layout.ts'
import { formatDurationMillis } from './trajectory-record.ts'
import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
/** Horizontal projection used by the trajectory timeline. */
@@ -34,14 +35,12 @@ export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
}
/**
* Format a timeline duration with a compact unit.
* Format a timeline duration as an integer-millisecond label.
* @param milliseconds - Non-negative duration in milliseconds.
* @returns Millisecond or second label.
* @returns Millisecond label with thousands separators.
*/
export function formatTimelineOffset(milliseconds: number): string {
if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
const seconds = milliseconds / 1_000
return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
return formatDurationMillis(milliseconds)
}
function laneFor(kind: TrajectoryCellKind): number {

View File

@@ -37,6 +37,8 @@ export interface TrajectorySourceBlock {
export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
/** 1-based record index shown as `#N`. */
index: number
/** Projection-stable identity when no single source event owns the record lifecycle. */
recordId?: string
kind: TrajectoryCellKind
/** Single-line summary; CSS ellipsis when it overflows. */
text: string
@@ -92,13 +94,32 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
}
/**
* Format own-duration for the trailing time column.
* Resolve the identity that survives prepending older projected records.
* @param cell - Projected trajectory record.
* @returns Stable identity from the owning event or tool call, with a fixture fallback.
*/
export function trajectoryRecordId(cell: TrajectoryCellProps): string {
if (cell.recordId !== undefined) return cell.recordId
if (cell.callId !== undefined) return `${cell.kind}\u0000call\u0000${cell.callId}`
if (cell.sourceSeq !== undefined) return `${cell.kind}\u0000seq\u0000${cell.sourceSeq}`
return `${cell.kind}\u0000index\u0000${cell.index}`
}
/**
* Format a duration in milliseconds with thousands separators.
* @param milliseconds - Duration in milliseconds, or `null` when absent.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatDurationMillis(milliseconds: number | null): string {
if (milliseconds === null || !Number.isFinite(milliseconds)) return '—'
return `${Math.round(milliseconds).toLocaleString('en-US')} ms`
}
/**
* Format an elapsed duration given in seconds as a millisecond label.
* @param seconds - Duration seconds, or `null` when absent.
* @returns `—` when unknown, otherwise a seconds label.
* @returns `—` when unknown, otherwise an integer-millisecond label.
*/
export function formatElapsedSeconds(seconds: number | null): string {
if (seconds === null || !Number.isFinite(seconds)) return '—'
const rounded = Math.round(seconds * 10) / 10
if (Number.isInteger(rounded)) return `${rounded} s`
return `${rounded.toFixed(1)} s`
return formatDurationMillis(seconds === null ? null : seconds * 1000)
}

View File

@@ -0,0 +1,83 @@
/** Pure projection from trajectory records to measurable virtual ledger rows. */
import type { TrajectoryCellProps } from './trajectory-record.ts'
import { trajectoryRecordId } from './trajectory-record.ts'
const CONTENT_ROW_HEIGHT = 30
const COLLAPSED_SUMMARY_HEIGHT = 20
const TERMINAL_BOUNDARY_HEIGHT = 9
/** Minimal record shape required by the trajectory virtual-row projection. */
export interface VirtualizableTrajectoryRecord {
cell: TrajectoryCellProps
collapsedSummaryKind?: 'turn' | 'assistant'
}
/** One logical record retained inside a measurable virtual row. */
export interface TrajectoryVirtualRowEntry<T extends VirtualizableTrajectoryRecord> {
logicalIndex: number
record: T
}
/** One virtualizer item, which may carry zero-height request boundaries. */
export interface TrajectoryVirtualRow<T extends VirtualizableTrajectoryRecord> {
entries: readonly TrajectoryVirtualRowEntry<T>[]
height: number
key: string
}
/**
* Derive the DOM-safe row identity shared by React, the virtualizer, and
* browser scroll contracts.
* @param record - Display record whose identity is required.
* @returns Stable record identity with a suffix for synthetic fold summaries.
*/
export function trajectoryVirtualRecordKey(
record: VirtualizableTrajectoryRecord,
): string {
const identity = encodeURIComponent(trajectoryRecordId(record.cell))
return record.collapsedSummaryKind === undefined
? identity
: `${identity}\u0000summary\u0000${record.collapsedSummaryKind}`
}
/**
* Attach separator-only records to the next content row so the virtualizer
* never owns a zero-height item. A terminal separator retains its CSS-owned
* lower-marker clearance as a standalone item.
* @param records - Final search/fold projection in ledger order.
* @returns Measurable virtual rows with original logical positions retained.
*/
export function groupTrajectoryVirtualRows<T extends VirtualizableTrajectoryRecord>(
records: readonly T[],
): readonly TrajectoryVirtualRow<T>[] {
const rows: TrajectoryVirtualRow<T>[] = []
let pending: TrajectoryVirtualRowEntry<T>[] = []
for (const [logicalIndex, record] of records.entries()) {
const entry = { logicalIndex, record }
if (record.cell.requestOnly === true) {
pending.push(entry)
continue
}
const entries = [...pending, entry]
pending = []
rows.push({
entries,
height: record.collapsedSummaryKind === undefined
? CONTENT_ROW_HEIGHT
: COLLAPSED_SUMMARY_HEIGHT,
key: trajectoryVirtualRecordKey(record),
})
}
if (pending.length > 0) {
rows.push({
entries: pending,
height: TERMINAL_BOUNDARY_HEIGHT,
key: pending.map(candidate => trajectoryVirtualRecordKey(candidate.record)).join('|'),
})
}
return rows
}