fix(client): refine trajectory inspection behavior

This commit is contained in:
_Kerman
2026-07-29 11:57:13 +08:00
parent 33afcc913b
commit 5d0d28cd0e
8 changed files with 147 additions and 21 deletions
@@ -1,7 +1,7 @@
// FoldAdapter: core SurfaceManager wiring + node materialization cache. // FoldAdapter: core SurfaceManager wiring + node materialization cache.
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index); // Padding sentinels solve the paged-window seq offset (core fold asserts seq === index).
// a cross-window replace throw degrades to a lenient linear scan (foldDegraded — // A replace that crosses the loaded window head uses a lenient linear scan until
// the degradation lives in one branch function in this file, zero scattered removal points). // paging reaches its range; unexpected fold failures report and use the same fallback.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// Subpath export (package.json exports "./surface", alias added for this): all value imports // Subpath export (package.json exports "./surface", alias added for this): all value imports
@@ -71,6 +71,17 @@ function paddingEvent(seq: number): SessionEvent {
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
} }
/**
* Whether a valid replacement range begins before the loaded history window.
* @param event - Candidate surface event in the current replay window.
* @param baseSeq - Sequence at the loaded window head.
* @returns True when strict folding requires an earlier page.
*/
function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean {
if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false
return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq
}
/** Minimal generation projection owned by the inspection adapter, not the core live surface. */ /** Minimal generation projection owned by the inspection adapter, not the core live surface. */
interface FoldedContext { interface FoldedContext {
generation: number generation: number
@@ -183,7 +194,7 @@ function materializeNode(
} }
} }
/** Window fold over the core SurfaceManager (sentinel padding for the seq offset; degrades to a linear scan on cross-window replace). */ /** Window fold over the core SurfaceManager with a lenient partial-history fallback. */
export class FoldAdapter { export class FoldAdapter {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */ /** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = [] private padded: SessionEvent[] = []
@@ -247,7 +258,7 @@ export class FoldAdapter {
for (const event of events) this.padded.push(event) for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded) this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear() this.nodeCache.clear()
this.degraded = false this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map() this.callIdx = new Map()
this.resultViews.clear() this.resultViews.clear()
this.contextGeneration = 0 this.contextGeneration = 0
@@ -281,6 +292,7 @@ export class FoldAdapter {
if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) { if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) {
this.contextRev++ this.contextRev++
} }
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.padded.push(event) this.padded.push(event)
this.indexCall(event, view) this.indexCall(event, view)
if (this.projectContexts) this.indexContextPrompt(event) if (this.projectContexts) this.indexContextPrompt(event)
@@ -162,6 +162,68 @@ describe('FoldAdapter', () => {
} }
}) })
it('silently degrades when a replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([
at(10, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 3 },
sourceEventSeqs: [1, 3],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}),
ev.user(11, 'newer message'),
], 10)
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('silently degrades when a live replacement needs an earlier history page', () => {
const adapter = new FoldAdapter()
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
adapter.reset([ev.user(10, 'window head')], 10)
adapter.append(at(11, {
type: 'assistant/message',
surfaceOp: { op: 'replace', start: 1, end: 1 },
sourceEventSeqs: [1],
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'live summary' }],
source: { kind: 'model', provider: 'fake', model: 'fake' },
}),
},
}))
expect(adapter.nodes()).toMatchObject({
degraded: true,
nodes: [{ seq: 10 }, { seq: 11 }],
})
expect(errorSpy).not.toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('materializes a tool-result error field when present', () => { it('materializes a tool-result error field when present', () => {
const adapter = new FoldAdapter() const adapter = new FoldAdapter()
adapter.reset([ adapter.reset([
@@ -740,7 +740,10 @@
} }
.detailBodySummary > .overview { .detailBodySummary > .overview {
flex: none; flex: 0 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
} }
.detailBodySummary > .compactedSummary { .detailBodySummary > .compactedSummary {
@@ -858,7 +861,7 @@
.overviewSections { .overviewSections {
display: flex; display: flex;
flex: 1; flex: 1;
min-height: 0; min-height: min-content;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
border-top: 0; border-top: 0;
@@ -866,17 +869,13 @@
.overviewSection { .overviewSection {
display: flex; display: flex;
flex: 0 1 auto; flex: 1 1 0;
min-height: 0; max-height: max-content;
min-height: 28px;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
} }
.overviewSection:has(> .overviewPreview > .overview),
.overviewSection:has(> .overviewPreview > .noPayload) {
flex-shrink: 0;
}
.overviewSection + .overviewSection { .overviewSection + .overviewSection {
padding-top: 8px; padding-top: 8px;
} }
@@ -225,6 +225,8 @@ export interface TrajectoryTableProps {
onSelectedIndexChange?: (index: number | null) => void onSelectedIndexChange?: (index: number | null) => void
/** Report a direct user selection from a ledger row. */ /** Report a direct user selection from a ledger row. */
onRecordSelect?: (index: number) => void onRecordSelect?: (index: number) => void
/** Clear selection state owned by the ledger host. */
onClearSelection?: () => void
/** Turn ids whose rows after the first are folded into a summary. */ /** Turn ids whose rows after the first are folded into a summary. */
collapsedTurns: ReadonlySet<number> collapsedTurns: ReadonlySet<number>
/** Toggle one turn between folded and expanded. */ /** Toggle one turn between folded and expanded. */
@@ -1384,6 +1386,7 @@ function OverviewSection({
/** /**
* Render trajectory events as a dense ledger with turn and step separators. * Render trajectory events as a dense ledger with turn and step separators.
* Clicking ledger whitespace clears the active record or request selection.
* @param props - Grouped trajectory data and whole-ledger fold state. * @param props - Grouped trajectory data and whole-ledger fold state.
* @returns The ledger and an optional local record inspector. * @returns The ledger and an optional local record inspector.
*/ */
@@ -1394,6 +1397,7 @@ export function TrajectoryTable({
searchMatchIndexes = null, searchMatchIndexes = null,
onSelectedIndexChange, onSelectedIndexChange,
onRecordSelect, onRecordSelect,
onClearSelection,
collapsedTurns, collapsedTurns,
onToggleTurn, onToggleTurn,
collapsedAssistants, collapsedAssistants,
@@ -1517,6 +1521,16 @@ export function TrajectoryTable({
setActiveTab(tab) setActiveTab(tab)
} }
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRequest(null)
}
const clearAllSelections = () => {
clearInspectorSelection()
onClearSelection?.()
}
const selectRecord = (index: number) => { const selectRecord = (index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index) const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index) onRecordSelect?.(index)
@@ -1562,7 +1576,12 @@ export function TrajectoryTable({
return ( return (
<div className={css.split} style={splitStyle}> <div className={css.split} style={splitStyle}>
<div className={css.tablePane}> <div
className={css.tablePane}
onClick={(event) => {
if (event.target === event.currentTarget) clearAllSelections()
}}
>
<table className={css.table}> <table className={css.table}>
<colgroup> <colgroup>
<col className={css.eventColumn} /> <col className={css.eventColumn} />
@@ -1918,10 +1937,7 @@ export function TrajectoryTable({
type="button" type="button"
className={css.close} className={css.close}
aria-label="Close details" aria-label="Close details"
onClick={() => { onClick={clearInspectorSelection}
setSelectedIndex(null)
setSelectedRequest(null)
}}
> >
<span aria-hidden="true">×</span> <span aria-hidden="true">×</span>
</button> </button>
@@ -15,7 +15,7 @@
box-sizing: border-box; box-sizing: border-box;
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 0 10px 0 12px; padding: 0 6px;
gap: 8px; gap: 8px;
} }
@@ -503,6 +503,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
searchMatchIndexes={searchMatchIndexes} searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex} onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect} onRecordSelect={handleRecordSelect}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns} collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn} onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants} collapsedAssistants={collapsedAssistants}
@@ -1,7 +1,7 @@
// @vitest-environment jsdom // @vitest-environment jsdom
/** Trajectory ledger selection, details, status, and fold behavior. */ /** Trajectory ledger selection, details, status, and fold behavior. */
import { afterEach, describe, expect, it } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts' import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -111,6 +111,30 @@ describe('TrajectoryTable', () => {
)).toBeTruthy() )).toBeTruthy()
}) })
it('clears the selected row when ledger whitespace is clicked', () => {
const onClearSelection = vi.fn()
render(
<TrajectoryTable
turns={TURNS}
{...FOLD_PROPS}
onClearSelection={onClearSelection}
/>,
)
const row = screen.getByRole('row', { name: /ASSISTANT/ })
fireEvent.click(row)
expect(row.getAttribute('aria-selected')).toBe('true')
expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
const tablePane = screen.getByRole('table').parentElement
expect(tablePane).not.toBeNull()
fireEvent.click(tablePane as HTMLElement)
expect(row.getAttribute('aria-selected')).toBe('false')
expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
expect(onClearSelection).toHaveBeenCalledOnce()
})
it('keeps running and failure semantics distinct from record roles', () => { it('keeps running and failure semantics distinct from record roles', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />) const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy()
@@ -245,6 +245,18 @@ describe('tab switching in ConversationRoot', () => {
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 }) fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside')
const tablePane = screen.getByRole('table').parentElement
expect(tablePane).not.toBeNull()
fireEvent.click(tablePane as HTMLElement)
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBeNull()
fireEvent.pointerDown(plot, { button: 0, clientX: 55, pointerId: 2 })
fireEvent.pointerMove(plot, { clientX: 95, pointerId: 2 })
fireEvent.pointerUp(plot, { clientX: 95, pointerId: 2 })
expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus')) expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
.toBe('outside') .toBe('outside')
fireEvent.contextMenu(plot) fireEvent.contextMenu(plot)