feat(web): unify tool-row expand interaction with IN/OUT card and trajectory Inspect

Every expandable tool row shares one interaction (whole-row toggle,
icon-to-chevron hover preview) and one expanded body: an IN/OUT
gutter-labeled card with per-section 150px scroll caps and sticky labels.
toolRowModel derives result output and the error first line, terminalFailed
surfaces a failing exit as the collapsed row's red dot, a hover Inspect
pill jumps to the call's trajectory record through a one-shot store
handoff, and the chat view keeps its scroll offset across view switches.
This commit is contained in:
Yif
2026-07-30 21:21:29 +08:00
parent 86fa88a012
commit 78e4d36214
38 changed files with 1241 additions and 268 deletions

View File

@@ -235,6 +235,10 @@ export interface TrajectoryTableProps {
collapsedAssistants: ReadonlySet<number>
/** Toggle tool calls under one assistant record. */
onToggleAssistant: (index: number) => void
/** One-shot cross-view inspect: open and scroll to this call's record. */
inspectCallId?: string | null
/** Acknowledge a consumed (or unresolvable) inspect request. */
onInspectApplied?: (() => void) | undefined
}
/** One request identity paired with its session-global number. */
@@ -1402,6 +1406,8 @@ export function TrajectoryTable({
onToggleTurn,
collapsedAssistants,
onToggleAssistant,
inspectCallId = null,
onInspectApplied,
}: TrajectoryTableProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null)
const [selectedRequest, setSelectedRequest] = useState<SelectedRequest | null>(null)
@@ -1574,8 +1580,37 @@ export function TrajectoryTable({
if (target !== undefined) openRecordSummary(target)
}
// Cross-view inspect handoff: resolve the requested call to its record,
// 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 pendingScrollIndex = useRef<number | null>(null)
const openRecordSummaryRef = useRef(openRecordSummary)
openRecordSummaryRef.current = openRecordSummary
useEffect(() => {
if (inspectCallId === null) return
const target = flattenRecords(turns).find(record => record.cell.callId === inspectCallId)
if (target === undefined) return
openRecordSummaryRef.current(target)
pendingScrollIndex.current = target.cell.index
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
/* v8 ignore next -- jsdom lacks scrollIntoView; browsers always have it. */
if (typeof row.scrollIntoView === 'function') {
row.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
})
return (
<div className={css.split} style={splitStyle}>
<div ref={rootRef} className={css.split} style={splitStyle}>
<div
className={css.tablePane}
onClick={(event) => {

View File

@@ -134,7 +134,7 @@ function searchMatches(
}
export function TrajectoryView({
useHistory, loadAllHistory,
useHistory, loadAllHistory, inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
@@ -502,6 +502,8 @@ export function TrajectoryView({
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
onToggleAssistant={toggleAssistant}
inspectCallId={inspect?.callId ?? null}
onInspectApplied={onInspectDone}
/>
</div>
</div>

View File

@@ -187,4 +187,50 @@ describe('TrajectoryTable', () => {
expect(screen.getByRole('row', { name: /ASSISTANT/ })).toBeTruthy()
expect(screen.getByRole('row', { name: /Collapsed turn summary/ })).toBeTruthy()
})
const CALL_TURNS: readonly TrajectoryTurnModel[] = [{
turn: 1,
groups: [{
title: 'Step 1',
cells: [{
index: 1,
kind: 'tool',
text: 'bash · {"command":"pwd"}',
inputDetail: '{"command":"pwd"}',
callId: 'call-1',
timeSeconds: 0.1,
}],
}],
}]
it('an inspect target opens the matching record and acknowledges once', () => {
const onInspectApplied = vi.fn()
render(
<TrajectoryTable
turns={CALL_TURNS}
{...FOLD_PROPS}
inspectCallId="call-1"
onInspectApplied={onInspectApplied}
/>,
)
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
expect(onInspectApplied).toHaveBeenCalledOnce()
})
it('an unmatched inspect target stays pending without acknowledgement', () => {
const onInspectApplied = vi.fn()
render(
<TrajectoryTable
turns={CALL_TURNS}
{...FOLD_PROPS}
inspectCallId="call-missing"
onInspectApplied={onInspectApplied}
/>,
)
expect(screen.getByRole('row', { name: /TOOL/ }).getAttribute('aria-selected')).toBe('false')
expect(onInspectApplied).not.toHaveBeenCalled()
})
})