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.
// 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 —
// the degradation lives in one branch function in this file, zero scattered removal points).
// Padding sentinels solve the paged-window seq offset (core fold asserts seq === index).
// A replace that crosses the loaded window head uses a lenient linear scan until
// paging reaches its range; unexpected fold failures report and use the same fallback.
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
// 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
}
/**
* 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. */
interface FoldedContext {
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 {
/** padded = [sentinel x baseSeq, ...window events]; SurfaceManager borrows this reference for lazy incremental folding. */
private padded: SessionEvent[] = []
@@ -247,7 +258,7 @@ export class FoldAdapter {
for (const event of events) this.padded.push(event)
this.surface = new SurfaceManager(this.padded)
this.nodeCache.clear()
this.degraded = false
this.degraded = events.some(event => replacementCrossesWindowHead(event, baseSeq))
this.callIdx = new Map()
this.resultViews.clear()
this.contextGeneration = 0
@@ -281,6 +292,7 @@ export class FoldAdapter {
if (this.projectContexts && (isSurfaceEvent(event) || event.type === 'request/header')) {
this.contextRev++
}
if (replacementCrossesWindowHead(event, this.baseSeq)) this.degraded = true
this.padded.push(event)
this.indexCall(event, view)
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', () => {
const adapter = new FoldAdapter()
adapter.reset([
@@ -740,7 +740,10 @@
}
.detailBodySummary > .overview {
flex: none;
flex: 0 1 auto;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.detailBodySummary > .compactedSummary {
@@ -858,7 +861,7 @@
.overviewSections {
display: flex;
flex: 1;
min-height: 0;
min-height: min-content;
flex-direction: column;
overflow: hidden;
border-top: 0;
@@ -866,17 +869,13 @@
.overviewSection {
display: flex;
flex: 0 1 auto;
min-height: 0;
flex: 1 1 0;
max-height: max-content;
min-height: 28px;
flex-direction: column;
overflow: hidden;
}
.overviewSection:has(> .overviewPreview > .overview),
.overviewSection:has(> .overviewPreview > .noPayload) {
flex-shrink: 0;
}
.overviewSection + .overviewSection {
padding-top: 8px;
}
@@ -225,6 +225,8 @@ export interface TrajectoryTableProps {
onSelectedIndexChange?: (index: number | null) => void
/** Report a direct user selection from a ledger row. */
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. */
collapsedTurns: ReadonlySet<number>
/** 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.
* Clicking ledger whitespace clears the active record or request selection.
* @param props - Grouped trajectory data and whole-ledger fold state.
* @returns The ledger and an optional local record inspector.
*/
@@ -1394,6 +1397,7 @@ export function TrajectoryTable({
searchMatchIndexes = null,
onSelectedIndexChange,
onRecordSelect,
onClearSelection,
collapsedTurns,
onToggleTurn,
collapsedAssistants,
@@ -1517,6 +1521,16 @@ export function TrajectoryTable({
setActiveTab(tab)
}
const clearInspectorSelection = () => {
setSelectedIndex(null)
setSelectedRequest(null)
}
const clearAllSelections = () => {
clearInspectorSelection()
onClearSelection?.()
}
const selectRecord = (index: number) => {
const record = allRecords.find(candidate => candidate.cell.index === index)
onRecordSelect?.(index)
@@ -1562,7 +1576,12 @@ export function TrajectoryTable({
return (
<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}>
<colgroup>
<col className={css.eventColumn} />
@@ -1918,10 +1937,7 @@ export function TrajectoryTable({
type="button"
className={css.close}
aria-label="Close details"
onClick={() => {
setSelectedIndex(null)
setSelectedRequest(null)
}}
onClick={clearInspectorSelection}
>
<span aria-hidden="true">×</span>
</button>
@@ -15,7 +15,7 @@
box-sizing: border-box;
width: 100%;
height: 100%;
padding: 0 10px 0 12px;
padding: 0 6px;
gap: 8px;
}
@@ -503,6 +503,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
searchMatchIndexes={searchMatchIndexes}
onSelectedIndexChange={setSelectedTimelineIndex}
onRecordSelect={handleRecordSelect}
onClearSelection={() => { setTimelineSelection(null) }}
collapsedTurns={collapsedTurns}
onToggleTurn={toggleTurn}
collapsedAssistants={collapsedAssistants}
@@ -1,7 +1,7 @@
// @vitest-environment jsdom
/** 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 { TrajectoryTable } from '../src/client/TrajectoryTable.tsx'
import type { TrajectoryTurnModel } from '../src/client/layout.ts'
@@ -111,6 +111,30 @@ describe('TrajectoryTable', () => {
)).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', () => {
const view = render(<TrajectoryTable turns={TURNS} {...FOLD_PROPS} />)
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.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'))
.toBe('outside')
fireEvent.contextMenu(plot)