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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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
|
||||
? {}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 !== ''
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user