diff --git a/packages/client/runtime/src/client/sessions/fold-adapter.ts b/packages/client/runtime/src/client/sessions/fold-adapter.ts index bbc906eb7b..d83d46ffe6 100644 --- a/packages/client/runtime/src/client/sessions/fold-adapter.ts +++ b/packages/client/runtime/src/client/sessions/fold-adapter.ts @@ -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) diff --git a/packages/client/runtime/tests/fold-adapter.spec.ts b/packages/client/runtime/tests/fold-adapter.spec.ts index 1ee0e23998..5f0f417601 100644 --- a/packages/client/runtime/tests/fold-adapter.spec.ts +++ b/packages/client/runtime/tests/fold-adapter.spec.ts @@ -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([ diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 49086e93d0..1b30318f68 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -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; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index b65b1feb02..09ad60b427 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -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 /** 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 (
-
+
{ + if (event.target === event.currentTarget) clearAllSelections() + }} + > @@ -1918,10 +1937,7 @@ export function TrajectoryTable({ type="button" className={css.close} aria-label="Close details" - onClick={() => { - setSelectedIndex(null) - setSelectedRequest(null) - }} + onClick={clearInspectorSelection} > diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css index 9f9254bbbc..6e3dd27916 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css @@ -15,7 +15,7 @@ box-sizing: border-box; width: 100%; height: 100%; - padding: 0 10px 0 12px; + padding: 0 6px; gap: 8px; } diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 06871675c5..1d50dd5560 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -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} diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index 7c0b567311..fb21ca45b7 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -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( + , + ) + 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() expect(view.container.querySelector('tr[data-kind="tool"][data-running="true"]')).toBeTruthy() diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 7cfce3e5c3..5f39acecb1 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -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)