Răsfoiți Sursa

fix(client): refine trajectory inspection behavior

_Kerman 2 luni în urmă
părinte
comite
5d0d28cd0e

+ 17 - 5
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)

+ 62 - 0
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([

+ 8 - 9
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;
 }

+ 21 - 5
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<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>

+ 1 - 1
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;
 }
 

+ 1 - 0
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}

+ 25 - 1
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(
+      <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()

+ 12 - 0
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)