fix(trajectory): preserve state across history prepends
This commit is contained in:
@@ -19,7 +19,7 @@ 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 { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts'
|
||||
import css from './TrajectoryTable.module.css'
|
||||
|
||||
@@ -27,6 +27,7 @@ const BOTTOM_FOLLOW_THRESHOLD_PX = 2
|
||||
const OLDER_LOAD_THRESHOLD_PX = 48
|
||||
const VIRTUALIZATION_THRESHOLD = 100
|
||||
const VIRTUAL_ROW_HEIGHT_PX = 30
|
||||
const COLLAPSED_SUMMARY_HEIGHT_PX = 20
|
||||
const VIRTUAL_FINAL_REQUEST_HEIGHT_PX = 9
|
||||
const VIRTUAL_OVERSCAN_ROWS = 12
|
||||
const VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX = 600
|
||||
@@ -157,9 +158,8 @@ interface ToolCallTextParts {
|
||||
|
||||
interface SelectedRequest {
|
||||
turn: number | null
|
||||
section: number
|
||||
number: number
|
||||
group: string
|
||||
seq?: number
|
||||
}
|
||||
|
||||
interface DetailsResizeDrag {
|
||||
@@ -333,7 +333,7 @@ export interface TrajectoryTableProps {
|
||||
recordFocus?: { readonly index: number } | null
|
||||
/** Whether the initial history tail is still loading. */
|
||||
historyLoading?: boolean
|
||||
/** First loaded history node, used to preserve scroll position after prepending a page. */
|
||||
/** 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
|
||||
@@ -345,10 +345,10 @@ export interface TrajectoryTableProps {
|
||||
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. */
|
||||
@@ -427,6 +427,7 @@ function flattenRecords(turns: readonly TrajectoryTurnModel[]): TableRecord[] {
|
||||
}
|
||||
|
||||
function virtualRecordHeight(record: TableRecord, final: boolean): number {
|
||||
if (record.collapsedSummary !== undefined) return COLLAPSED_SUMMARY_HEIGHT_PX
|
||||
if (record.cell.requestOnly !== true) return VIRTUAL_ROW_HEIGHT_PX
|
||||
return final ? VIRTUAL_FINAL_REQUEST_HEIGHT_PX : 0
|
||||
}
|
||||
@@ -581,14 +582,17 @@ function summarizeAssistantTools(records: readonly TableRecord[]): string {
|
||||
|
||||
function collapseAssistantRecords(
|
||||
records: readonly TableRecord[],
|
||||
collapsedAssistants: ReadonlySet<number>,
|
||||
collapsedAssistants: ReadonlySet<string>,
|
||||
): TableRecord[] {
|
||||
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]
|
||||
@@ -1581,7 +1585,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)
|
||||
@@ -1596,14 +1600,18 @@ export function TrajectoryTable({
|
||||
const followsTableTail = useRef(false)
|
||||
const tableScrollInitialized = useRef(false)
|
||||
const [tableScrollReady, setTableScrollReady] = useState(false)
|
||||
const pendingScrollIndex = useRef<number | null>(null)
|
||||
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 selected = selectedRecordId === null
|
||||
? undefined
|
||||
: allRecords.find(record => trajectoryRecordId(record.cell) === selectedRecordId)
|
||||
const selectedIndex = selected?.cell.index ?? null
|
||||
useEffect(() => {
|
||||
onSelectedIndexChange?.(selectedIndex)
|
||||
}, [onSelectedIndexChange, selectedIndex])
|
||||
const allRecords = useMemo(() => flattenRecords(turns), [turns])
|
||||
const requestNumbers = useMemo(
|
||||
() => indexRequestNumbers(allRecords, sessionRequestNumbers),
|
||||
[allRecords, sessionRequestNumbers],
|
||||
@@ -1631,7 +1639,7 @@ export function TrajectoryTable({
|
||||
const record = records[index]
|
||||
return record === undefined
|
||||
? index
|
||||
: `${record.cell.index}:${record.collapsedSummaryKind ?? 'record'}`
|
||||
: `${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}`
|
||||
},
|
||||
getScrollElement: () => tablePaneRef.current,
|
||||
initialRect: { width: 0, height: VIRTUAL_INITIAL_VIEWPORT_HEIGHT_PX },
|
||||
@@ -1649,7 +1657,6 @@ export function TrajectoryTable({
|
||||
})
|
||||
: records.map((record, position) => ({ record, position }))
|
||||
const requestBoundaryRuns = indexRequestBoundaryRuns(records)
|
||||
const selected = allRecords.find(record => record.cell.index === selectedIndex)
|
||||
const selectedPrompt = selected?.cell.kind === 'system'
|
||||
? selected.cell.promptDetail
|
||||
: undefined
|
||||
@@ -1662,16 +1669,20 @@ export function TrajectoryTable({
|
||||
? []
|
||||
: allRecords.filter(record =>
|
||||
record.turn === selectedRequest.turn
|
||||
&& record.section === selectedRequest.section
|
||||
&& record.group === selectedRequest.group,
|
||||
)
|
||||
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
|
||||
@@ -1715,7 +1726,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)
|
||||
@@ -1727,13 +1740,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
|
||||
@@ -1752,7 +1769,7 @@ export function TrajectoryTable({
|
||||
}
|
||||
|
||||
const clearInspectorSelection = () => {
|
||||
setSelectedIndex(null)
|
||||
setSelectedRecordId(null)
|
||||
setSelectedRequest(null)
|
||||
}
|
||||
|
||||
@@ -1765,7 +1782,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))
|
||||
@@ -1779,19 +1796,25 @@ export function TrajectoryTable({
|
||||
) return
|
||||
appliedRecordSelection.current = recordSelection
|
||||
selectRecord(recordSelection.index)
|
||||
pendingScrollIndex.current = 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
|
||||
pendingScrollIndex.current = recordFocus.index
|
||||
}, [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)
|
||||
}
|
||||
@@ -1804,12 +1827,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')
|
||||
}
|
||||
|
||||
@@ -1829,22 +1853,24 @@ 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 id = pendingScrollRecordId.current
|
||||
if (id === null) return
|
||||
const position = records.findIndex(record =>
|
||||
record.cell.index === index && record.collapsedSummary === undefined)
|
||||
trajectoryRecordId(record.cell) === id && record.collapsedSummary === undefined)
|
||||
if (position === -1) return
|
||||
pendingScrollIndex.current = null
|
||||
pendingScrollRecordId.current = null
|
||||
if (virtualizationEnabled) {
|
||||
rowVirtualizer.scrollToIndex(position, { behavior: 'smooth', align: 'center' })
|
||||
return
|
||||
}
|
||||
const row = rootRef.current
|
||||
?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
|
||||
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 (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
|
||||
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
@@ -2027,14 +2053,13 @@ 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={`${trajectoryRecordId(record.cell)}:${record.collapsedSummaryKind ?? 'record'}`}
|
||||
tabIndex={isRequestOnly ? -1 : 0}
|
||||
aria-label={isCollapsedSummary
|
||||
? `Collapsed ${record.collapsedSummaryKind} summary, ${record.collapsedSummary}`
|
||||
@@ -2064,7 +2089,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) => {
|
||||
@@ -2079,7 +2104,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
|
||||
@@ -2098,7 +2123,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)
|
||||
@@ -2121,9 +2146,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() }}
|
||||
@@ -2355,7 +2379,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'
|
||||
@@ -2655,7 +2679,7 @@ export function TrajectoryTable({
|
||||
selectRequest(selectedAssistantRequestTarget)
|
||||
}}
|
||||
>
|
||||
<span>Request #{selectedAssistantRequestTarget.number}</span>
|
||||
<span>Request #{selectedAssistantRequest ?? '—'}</span>
|
||||
<IconChevronRightOutline14
|
||||
className={css.overviewHierarchyJumpIconTight}
|
||||
size={11}
|
||||
|
||||
@@ -155,7 +155,7 @@
|
||||
top: calc(var(--trajectory-span-lane) * 14px);
|
||||
left: calc(var(--trajectory-span-left) + var(--trajectory-span-gap));
|
||||
width: max(
|
||||
0px,
|
||||
2px,
|
||||
calc(
|
||||
var(--trajectory-span-width)
|
||||
- var(--trajectory-span-gap)
|
||||
@@ -163,6 +163,7 @@
|
||||
)
|
||||
);
|
||||
height: 8px;
|
||||
min-width: 2px;
|
||||
border-radius: 1px;
|
||||
background: var(--dsw-alias-label-secondary);
|
||||
opacity: 0.78;
|
||||
|
||||
@@ -26,9 +26,11 @@ import {
|
||||
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
|
||||
@@ -172,15 +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, 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)
|
||||
@@ -197,6 +207,7 @@ export function TrajectoryView({
|
||||
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
|
||||
@@ -382,11 +393,23 @@ export function TrajectoryView({
|
||||
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 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(
|
||||
@@ -420,14 +443,16 @@ export function TrajectoryView({
|
||||
const allTurnsCollapsed = collapsibleTurnIds.length > 0
|
||||
&& collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
|
||||
const collapsibleAssistantIds = useMemo(() => {
|
||||
const ids: number[] = []
|
||||
const ids: string[] = []
|
||||
for (const turn of turns) {
|
||||
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
|
||||
@@ -456,11 +481,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
|
||||
})
|
||||
}
|
||||
@@ -514,7 +539,7 @@ export function TrajectoryView({
|
||||
searchMatchIndexes={searchMatchIndexes}
|
||||
onRangeChange={(range) => {
|
||||
setTimelineSelection(range === null ? null : {
|
||||
branchId: currentBranch.id,
|
||||
branchKey: currentBranch.key,
|
||||
range,
|
||||
})
|
||||
}}
|
||||
@@ -529,7 +554,7 @@ export function TrajectoryView({
|
||||
/>
|
||||
<div className={css.ledger}>
|
||||
<TrajectoryTable
|
||||
key={currentBranch.id}
|
||||
key={currentBranch.key}
|
||||
requestNumbers={requestNumbers}
|
||||
turns={turns}
|
||||
timelineFocusIndexes={timelineFocusIndexes}
|
||||
@@ -539,7 +564,7 @@ export function TrajectoryView({
|
||||
recordSelection={timelineRecordSelection}
|
||||
recordFocus={timelineRecordFocus}
|
||||
historyLoading={historyLoading}
|
||||
historyStartSeq={nodes.at(0)?.seq}
|
||||
historyStartSeq={historyBaseSeq}
|
||||
hasOlderRecords={hasOlderHistory}
|
||||
onLoadOlder={loadEarlierHistory}
|
||||
onClearSelection={() => { setTimelineSelection(null) }}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -517,10 +517,16 @@ export function appendTrajectoryPartialLayout(
|
||||
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),
|
||||
...group.cells.filter(cell =>
|
||||
cell.requestOnly !== true
|
||||
&& (cell.callId === undefined || !streamedCallIds.has(cell.callId)),
|
||||
),
|
||||
...streamedGroup.cells,
|
||||
],
|
||||
}
|
||||
@@ -641,6 +647,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 !== ''
|
||||
|
||||
@@ -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
|
||||
@@ -91,6 +93,18 @@ export interface TrajectoryCellProps extends HTMLAttributes<HTMLDivElement> {
|
||||
selected?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 own-duration for the trailing time column.
|
||||
* @param seconds - Duration seconds, or `null` when absent.
|
||||
|
||||
Reference in New Issue
Block a user