Просмотр исходного кода

feat(ui): refine trajectory timeline interaction

_Kerman 2 месяцев назад
Родитель
Сommit
997932ffd6

+ 16 - 4
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1,6 +1,6 @@
 /** Turn-aware trajectory event ledger with a local record inspector. */
 
-import { useEffect, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import {
   extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
@@ -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
+  /** One externally requested record selection; a new object repeats the request. */
+  recordSelection?: { readonly index: number } | null
   /** Clear selection state owned by the ledger host. */
   onClearSelection?: () => void
   /** Turn ids whose rows after the first are folded into a summary. */
@@ -1397,6 +1399,7 @@ export function TrajectoryTable({
   searchMatchIndexes = null,
   onSelectedIndexChange,
   onRecordSelect,
+  recordSelection = null,
   onClearSelection,
   collapsedTurns,
   onToggleTurn,
@@ -1410,11 +1413,12 @@ export function TrajectoryTable({
   const [detailsWidth, setDetailsWidth] = useState<number | null>(null)
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
   const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
+  const appliedRecordSelection = useRef<TrajectoryTableProps['recordSelection']>(null)
   const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
   useEffect(() => {
     onSelectedIndexChange?.(selectedIndex)
   }, [onSelectedIndexChange, selectedIndex])
-  const allRecords = flattenRecords(turns)
+  const allRecords = useMemo(() => flattenRecords(turns), [turns])
   const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
   const records = searchMatchIndexes === null
     ? collapseAssistantRecords(
@@ -1531,7 +1535,7 @@ export function TrajectoryTable({
     onClearSelection?.()
   }
 
-  const selectRecord = (index: number) => {
+  const selectRecord = useCallback((index: number) => {
     const record = allRecords.find(candidate => candidate.cell.index === index)
     onRecordSelect?.(index)
     setSelectedRequest(null)
@@ -1541,7 +1545,15 @@ export function TrajectoryTable({
     const available = new Set(tabs.map(tab => tab.id))
     const recent = [...tabHistory.current].reverse().find(tab => available.has(tab))
     setActiveTab(recent ?? tabs[0]?.id ?? 'overview')
-  }
+  }, [allRecords, onRecordSelect])
+  useEffect(() => {
+    if (
+      recordSelection === null
+      || appliedRecordSelection.current === recordSelection
+    ) return
+    appliedRecordSelection.current = recordSelection
+    selectRecord(recordSelection.index)
+  }, [recordSelection, selectRecord])
 
   const selectRequest = (
     request: SelectedRequest,

+ 27 - 2
packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css

@@ -70,16 +70,29 @@
 .lanes {
   position: absolute;
   z-index: 2;
-  inset: 7px 0;
+  top: 7px;
+  bottom: 7px;
+  left: var(--trajectory-domain-left);
+  width: var(--trajectory-domain-width);
 }
 
 .turnBoundaries {
   position: absolute;
   z-index: 3;
-  inset: 0;
+  top: 0;
+  bottom: 0;
+  left: var(--trajectory-domain-left);
+  width: var(--trajectory-domain-width);
   pointer-events: none;
 }
 
+@media (prefers-reduced-motion: no-preference) {
+  .lanes[data-animate-viewport='true'],
+  .turnBoundaries[data-animate-viewport='true'] {
+    transition: left 180ms ease-out;
+  }
+}
+
 .turnBoundary {
   position: absolute;
   top: 0;
@@ -142,6 +155,18 @@
   opacity: 0.2;
 }
 
+.span[data-hovered='true']:not([data-current='true']) {
+  z-index: 1;
+  opacity: 0.78;
+  box-shadow:
+    0 0 0 1px var(--dsw-alias-bg-layer-2),
+    0 0 0 2px color-mix(
+      in srgb,
+      var(--dsw-alias-state-business-primary) 80%,
+      transparent
+    );
+}
+
 .span[data-current='true'] {
   z-index: 1;
   opacity: 1;

+ 169 - 43
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx

@@ -15,12 +15,20 @@ import css from './TrajectoryTimeline.module.css'
 
 const MINIMUM_DRAG_PX = 3
 const MINIMUM_ZOOM_OPERATIONS = 4
+const EDGE_PAN_ZONE_FRACTION = 0.08
+const EDGE_PAN_STEP_FRACTION = 0.025
+const MAXIMUM_EDGE_PAN_PX = 32
 
 interface FractionRange {
   start: number
   end: number
 }
 
+interface HoverPoint {
+  fraction: number
+  recordIndex: number | null
+}
+
 /** Props for the fixed full-domain overview above the trajectory ledger. */
 export interface TrajectoryTimelineProps {
   turns: readonly TrajectoryTurnModel[]
@@ -30,6 +38,9 @@ export interface TrajectoryTimelineProps {
   /** Record indexes matching the active ledger search, or null without a query. */
   searchMatchIndexes?: ReadonlySet<number> | null
   onRangeChange: (range: TrajectoryTimeRange | null) => void
+  /** Select a directly clicked timeline block. */
+  onRecordSelect?: (index: number) => void
+  /** Bring the nearest record into view after clicking timeline whitespace. */
   onRecordFocus?: (index: number) => void
 }
 
@@ -41,11 +52,16 @@ function clampFraction(value: number): number {
   return Math.min(1, Math.max(0, value))
 }
 
-function centeredRange(center: number, width: number): FractionRange {
-  const clampedWidth = Math.min(1, Math.max(0, width))
+function centeredRange(
+  center: number,
+  width: number,
+  minimum: number,
+  maximum: number,
+): FractionRange {
+  const clampedWidth = Math.min(maximum - minimum, Math.max(0, width))
   const start = Math.min(
-    Math.max(center - clampedWidth / 2, 0),
-    1 - clampedWidth,
+    Math.max(center - clampedWidth / 2, minimum),
+    maximum - clampedWidth,
   )
   return { start, end: start + clampedWidth }
 }
@@ -79,6 +95,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
   selectedIndex = null,
   searchMatchIndexes = null,
   onRangeChange,
+  onRecordSelect,
   onRecordFocus,
 }: TrajectoryTimelineProps) {
   const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
@@ -94,10 +111,16 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     )),
     [turns],
   )
-  const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
-  const [draft, setDraft] = useState<FractionRange | null>(null)
-  const [hover, setHover] = useState<number | null>(null)
+  const dragRef = useRef<{
+    pointerId: number
+    anchorTime: number
+    anchorClientX: number
+    recordIndex: number | null
+  } | null>(null)
+  const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
+  const [hover, setHover] = useState<HoverPoint | null>(null)
   const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
+  const [animateViewport, setAnimateViewport] = useState(false)
   useEffect(() => {
     if (
       model !== null
@@ -109,11 +132,35 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
   }, [model, onRangeChange, range])
   useEffect(() => {
     if (model === null) return
+    setAnimateViewport(false)
     setViewport(current =>
       current !== null && (current.end < model.start || current.start > model.end)
         ? null
         : current)
   }, [model])
+  useEffect(() => {
+    if (model === null || selectedIndex === null) return
+    const selectedSpan = model.spans.find(span => span.index === selectedIndex)
+    if (selectedSpan === undefined) return
+    setAnimateViewport(true)
+    setViewport((current) => {
+      if (current === null) return current
+      if (
+        selectedSpan.end > current.start
+        && selectedSpan.start < current.end
+      ) return current
+      const duration = Math.max(1, current.end - current.start)
+      const desiredStart = selectedSpan.end <= current.start
+        ? selectedSpan.start
+        : selectedSpan.end - duration
+      const nextStart = Math.min(
+        Math.max(desiredStart, model.start),
+        Math.max(model.start, model.end - duration),
+      )
+      if (nextStart === current.start) return current
+      return { start: nextStart, end: nextStart + duration }
+    })
+  }, [model, selectedIndex])
   const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
   const viewportDuration = Math.min(
     fullDuration,
@@ -127,16 +174,21 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     )
   const domainDuration = viewport === null ? fullDuration : viewportDuration
   const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
+  const projectedDomainStyle = model === null
+    ? undefined
+    : {
+      '--trajectory-domain-left':
+        `${-(domainStart - model.start) / domainDuration * 100}%`,
+      '--trajectory-domain-width': `${fullDuration / domainDuration * 100}%`,
+    } as CSSProperties
   const committed = model === null || range === null
     ? null
     : rangeFraction(range, domainStart, domainDuration)
-  const visibleRange = draft ?? committed
-  const activeRange = draft === null
-    ? range
-    : {
-      start: domainStart + draft.start * domainDuration,
-      end: domainStart + draft.end * domainDuration,
-    }
+  const draftFraction = model === null || draft === null
+    ? null
+    : rangeFraction(draft, domainStart, domainDuration)
+  const visibleRange = draftFraction ?? committed
+  const activeRange = draft ?? range
 
   if (model === null) {
     return (
@@ -151,9 +203,9 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     )
   }
 
-  const minimumSelectionFraction = Math.min(
-    1,
-    fullDuration / domainDuration / model.spans.length,
+  const minimumSelectionDuration = Math.min(
+    domainDuration,
+    fullDuration / model.spans.length,
   )
 
   const fractionAt = (event: PointerEvent<HTMLDivElement>): number => {
@@ -161,51 +213,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     return clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
   }
 
-  const commit = (fraction: FractionRange) => {
-    onRangeChange({
-      start: domainStart + fraction.start * domainDuration,
-      end: domainStart + fraction.end * domainDuration,
-    })
+  const recordIndexAt = (event: PointerEvent<HTMLDivElement>): number | null => {
+    const target = event.target instanceof HTMLElement ? event.target : null
+    const value = target?.closest<HTMLElement>('[data-timeline-record-index]')
+      ?.dataset.timelineRecordIndex
+    if (value === undefined) return null
+    const index = Number(value)
+    return Number.isFinite(index) ? index : null
+  }
+
+  const commit = (nextRange: TrajectoryTimeRange) => {
+    onRangeChange(nextRange)
   }
 
   const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
     if (event.button !== 0) return
-    const rect = event.currentTarget.getBoundingClientRect()
     const anchor = fractionAt(event)
-    setHover(anchor)
-    dragRef.current = { pointerId: event.pointerId, anchor, width: Math.max(1, rect.width) }
+    const anchorTime = domainStart + anchor * domainDuration
+    const recordIndex = recordIndexAt(event)
+    setHover({ fraction: anchor, recordIndex })
+    dragRef.current = {
+      pointerId: event.pointerId,
+      anchorTime,
+      anchorClientX: event.clientX,
+      recordIndex,
+    }
     if (typeof event.currentTarget.setPointerCapture === 'function') {
       event.currentTarget.setPointerCapture(event.pointerId)
     }
-    setDraft({ start: anchor, end: anchor })
+    setDraft({ start: anchorTime, end: anchorTime })
   }
 
   const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
     const drag = dragRef.current
+    const rect = event.currentTarget.getBoundingClientRect()
     const fraction = fractionAt(event)
-    setHover(fraction)
+    setHover({ fraction, recordIndex: recordIndexAt(event) })
     if (drag === null || drag.pointerId !== event.pointerId) return
-    setDraft(orderedRange(drag.anchor, fraction))
+    let nextDomainStart = domainStart
+    if (viewport !== null) {
+      const localX = event.clientX - rect.left
+      const edgeWidth = Math.min(
+        MAXIMUM_EDGE_PAN_PX,
+        Math.max(1, rect.width * EDGE_PAN_ZONE_FRACTION),
+      )
+      const direction = localX < edgeWidth
+        ? -1
+        : localX > rect.width - edgeWidth ? 1 : 0
+      if (direction !== 0) {
+        const edgeDistance = direction < 0
+          ? edgeWidth - localX
+          : localX - (rect.width - edgeWidth)
+        const strength = clampFraction(edgeDistance / edgeWidth)
+        const desiredStart = domainStart
+          + direction * domainDuration * EDGE_PAN_STEP_FRACTION
+          * Math.max(0.2, strength)
+        nextDomainStart = Math.min(
+          Math.max(desiredStart, model.start),
+          model.end - domainDuration,
+        )
+        if (nextDomainStart !== domainStart) {
+          setAnimateViewport(false)
+          setViewport({
+            start: nextDomainStart,
+            end: nextDomainStart + domainDuration,
+          })
+        }
+      }
+    }
+    const pointTime = nextDomainStart + fraction * domainDuration
+    setDraft(orderedRange(drag.anchorTime, pointTime))
   }
 
   const onPointerEnd = (event: PointerEvent<HTMLDivElement>) => {
     const drag = dragRef.current
     if (drag === null || drag.pointerId !== event.pointerId) return
-    const point = fractionAt(event)
-    const selected = orderedRange(drag.anchor, point)
-    setHover(point)
+    const pointFraction = fractionAt(event)
+    const pointTime = domainStart + pointFraction * domainDuration
+    const selected = orderedRange(drag.anchorTime, pointTime)
+    setHover({ fraction: pointFraction, recordIndex: recordIndexAt(event) })
     dragRef.current = null
     setDraft(null)
-    const click = (selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX
-    const committedRange = selected.end - selected.start < minimumSelectionFraction
+    const click = Math.abs(event.clientX - drag.anchorClientX) < MINIMUM_DRAG_PX
+    const clickedSpan = click && drag.recordIndex !== null
+      ? model.spans.find(span => span.index === drag.recordIndex)
+      : undefined
+    if (clickedSpan !== undefined) {
+      onRangeChange(null)
+      onRecordSelect?.(clickedSpan.index)
+      return
+    }
+    const committedRange = selected.end - selected.start < minimumSelectionDuration
       ? centeredRange(
         click ? selected.start : (selected.start + selected.end) / 2,
-        minimumSelectionFraction,
+        minimumSelectionDuration,
+        model.start,
+        model.end,
       )
       : selected
     commit(committedRange)
     if (click) {
-      const timelinePoint = domainStart + selected.start * domainDuration
+      const timelinePoint = selected.start
       const nearest = model.spans.reduce((candidate, span) => {
         const candidateDistance = timelinePoint < candidate.start
           ? candidate.start - timelinePoint
@@ -233,6 +341,7 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
 
   const onWheel = (event: WheelEvent<HTMLDivElement>) => {
     event.preventDefault()
+    setAnimateViewport(false)
     const rect = event.currentTarget.getBoundingClientRect()
     const anchorFraction =
       clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
@@ -278,16 +387,18 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
           onWheel={onWheel}
           onContextMenu={(event) => {
             event.preventDefault()
+            setAnimateViewport(false)
             onRangeChange(null)
             setViewport(null)
           }}
         >
-          {hover !== null && draft === null && (
+          {hover !== null && hover.recordIndex === null && draft === null && (
             <div
               className={css.hoverLine}
+              data-timeline-hover-line
               aria-hidden="true"
               style={{
-                '--trajectory-hover-left': `${hover * 100}%`,
+                '--trajectory-hover-left': `${hover.fraction * 100}%`,
               } as CSSProperties}
             />
           )}
@@ -313,7 +424,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
               />
             </>
           )}
-          <div className={css.turnBoundaries} aria-hidden="true">
+          <div
+            className={css.turnBoundaries}
+            data-animate-viewport={animateViewport || undefined}
+            aria-hidden="true"
+            style={projectedDomainStyle}
+          >
             {model.turnBoundaries
               .slice(1)
               .filter(boundary =>
@@ -326,24 +442,34 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
                   key={boundary.turn}
                   style={{
                     '--trajectory-turn-left':
-                      `${(boundary.time - domainStart) / domainDuration * 100}%`,
+                      `${(boundary.time - model.start) / fullDuration * 100}%`,
                   } as CSSProperties}
                 />
               ))}
           </div>
-          <div className={css.lanes} aria-hidden="true">
+          <div
+            className={css.lanes}
+            data-animate-viewport={animateViewport || undefined}
+            data-timeline-domain
+            aria-hidden="true"
+            style={projectedDomainStyle}
+          >
             {model.spans
-              .filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
+              .filter(span =>
+                span.index === selectedIndex
+                || (span.end >= domainStart && span.start <= domainStart + domainDuration))
               .map((span) => {
-                const left = (span.start - domainStart) / domainDuration
-                const width = (span.end - span.start) / domainDuration
+                const left = (span.start - model.start) / fullDuration
+                const width = (span.end - span.start) / fullDuration
                 const durationMs = durationByIndex.get(span.index)
                 return (
                   <span
                     className={css.span}
                     data-timeline-span={span.kind}
+                    data-timeline-record-index={span.index}
                     data-equal-duration={mode === 'time' || undefined}
                     data-current={span.index === selectedIndex || undefined}
+                    data-hovered={hover?.recordIndex === span.index || undefined}
                     data-search-match={searchMatchIndexes === null
                       ? undefined
                       : searchMatchIndexes.has(span.index) ? 'true' : 'false'}

+ 18 - 1
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -147,6 +147,9 @@ export function TrajectoryView({
   const [actualTime, setActualTime] = useState(false)
   const [searchQuery, setSearchQuery] = useState('')
   const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
+  const [timelineRecordSelection, setTimelineRecordSelection] = useState<{
+    readonly index: number
+  } | null>(null)
   const ledgerRef = useRef<HTMLDivElement>(null)
   const inspection = useHistory(snapshot => snapshot.inspection)
   const nodes = inspection.eventNodes
@@ -478,7 +481,20 @@ export function TrajectoryView({
         selectedIndex={selectedTimelineIndex}
         searchMatchIndexes={searchMatchIndexes}
         onRangeChange={(range) => {
-          setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
+          setTimelineSelection(range === null ? null : {
+            branchId: currentBranch.id,
+            range,
+          })
+        }}
+        onRecordSelect={(index) => {
+          setTimelineSelection(null)
+          setTimelineRecordSelection({ index })
+          setSelectedTimelineIndex(index)
+          const row = ledgerRef.current
+            ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
+          if (row !== undefined && row !== null && typeof row.scrollIntoView === 'function') {
+            row.scrollIntoView({ behavior: 'smooth', block: 'center' })
+          }
         }}
         onRecordFocus={(index) => {
           const row = ledgerRef.current
@@ -497,6 +513,7 @@ export function TrajectoryView({
           searchMatchIndexes={searchMatchIndexes}
           onSelectedIndexChange={setSelectedTimelineIndex}
           onRecordSelect={handleRecordSelect}
+          recordSelection={timelineRecordSelection}
           onClearSelection={() => { setTimelineSelection(null) }}
           collapsedTurns={collapsedTurns}
           onToggleTurn={toggleTurn}

+ 130 - 0
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -25,6 +25,7 @@ import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/cli
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
 import type { TrajectoryTurnModel } from '../src/client/layout.ts'
+import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
 import {
   TrajectoryView, type TrajectoryViewInjected,
 } from '../src/client/TrajectoryView.tsx'
@@ -309,6 +310,44 @@ describe('tab switching in ConversationRoot', () => {
       .toBeNull()
   })
 
+  it('clicking a timeline block clears the range, selects the record, and opens its inspector', async () => {
+    const b = await bench()
+    const view = mount(b.slots)
+    fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
+      toJSON: () => ({}),
+    })
+    const toolSpan = view.container.querySelector<HTMLElement>(
+      '[data-timeline-span="tool"]',
+    )
+    expect(toolSpan).not.toBeNull()
+    const recordIndex = toolSpan?.dataset.timelineRecordIndex
+    expect(recordIndex).toBeTruthy()
+
+    fireEvent.pointerMove(toolSpan as HTMLElement, { clientX: 50, pointerId: 1 })
+    expect(view.container.querySelector('[data-timeline-hover-line]')).toBeNull()
+    expect(toolSpan?.getAttribute('data-hovered')).toBe('true')
+
+    fireEvent.pointerDown(plot, { button: 0, clientX: 5, pointerId: 1 })
+    fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
+    fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
+    expect(view.container.querySelector('tr[data-timeline-focus]')).toBeTruthy()
+
+    fireEvent.pointerDown(toolSpan as HTMLElement, {
+      button: 0, clientX: 50, pointerId: 2,
+    })
+    fireEvent.pointerUp(toolSpan as HTMLElement, { clientX: 50, pointerId: 2 })
+
+    const selectedRow = view.container.querySelector<HTMLElement>(
+      `tr[data-record-index="${recordIndex}"]`,
+    )
+    expect(selectedRow?.getAttribute('aria-selected')).toBe('true')
+    expect(view.container.querySelector('tr[data-timeline-focus]')).toBeNull()
+    expect(screen.getByRole('complementary', { name: 'Event details' })).toBeTruthy()
+  })
+
   it('empty window keeps the toolbar and reports no timing data', async () => {
     const b = await bench(historySnapshot([]))
     mount(b.slots)
@@ -332,6 +371,97 @@ describe('timeline projection', () => {
       ],
     }],
   }] satisfies readonly TrajectoryTurnModel[]
+  const longTurns = [{
+    turn: 1,
+    groups: [{
+      title: 'Step 1',
+      cells: Array.from({ length: 10 }, (_, index) => ({
+        index,
+        kind: 'message' as const,
+        text: `record ${index}`,
+        timeSeconds: 1,
+      })),
+    }],
+  }] satisfies readonly TrajectoryTurnModel[]
+
+  it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
+    const onRangeChange = vi.fn()
+    const view = render(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
+      toJSON: () => ({}),
+    })
+    fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
+
+    view.rerender(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        selectedIndex={1}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    await vi.waitFor(() => {
+      const domain = view.container.querySelector<HTMLElement>(
+        '[data-timeline-domain]',
+      )
+      expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-25%')
+    })
+
+    view.rerender(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        selectedIndex={8}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    await vi.waitFor(() => {
+      const domain = view.container.querySelector<HTMLElement>(
+        '[data-timeline-domain]',
+      )
+      expect(domain?.style.getPropertyValue('--trajectory-domain-left')).toBe('-125%')
+    })
+  })
+
+  it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
+    const onRangeChange = vi.fn()
+    render(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        onRangeChange={onRangeChange}
+      />,
+    )
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 0, y: 0, left: 0, top: 0, right: 100, bottom: 72, width: 100, height: 72,
+      toJSON: () => ({}),
+    })
+    fireEvent.wheel(plot, { clientX: 50, deltaY: -1_000 })
+    fireEvent.pointerDown(plot, { button: 0, clientX: 50, pointerId: 1 })
+    for (let index = 0; index < 24; index++) {
+      fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
+    }
+    fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
+
+    const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
+      | { start: number; end: number }
+      | undefined
+    expect(selectedRange).toBeDefined()
+    expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
+  })
 
   it('uses equal-width operation slots and stable semantic lanes', () => {
     expect(deriveTrajectoryTimeline(turns)).toEqual({