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

feat(client): refine trajectory timeline interaction

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

+ 5 - 5
apps/web/tests/navigation-panes.e2e.ts

@@ -159,7 +159,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
 
   it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline'))
-    const plot = page.getByLabel('Timeline overview; drag horizontally to filter events')
+    const plot = page.getByLabel('Timeline overview; drag horizontally to focus events')
     const before = await page.locator('tr[data-kind]').count()
     const box = await plot.boundingBox()
     if (box === null) throw new Error('trajectory timeline plot has no layout box')
@@ -167,11 +167,11 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await page.mouse.down()
     await page.mouse.move(box.x + box.width * 0.9, box.y + box.height / 2)
     await page.mouse.up()
-    await page.getByRole('button', { name: 'Clear selection' }).waitFor()
-    await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 })
-      .toBeLessThan(before)
-    await page.getByRole('button', { name: 'Clear selection' }).click()
+    await expect.poll(() => page.locator('tr[data-timeline-focus="outside"]').count(), { timeout: 10_000 })
+      .toBeGreaterThan(0)
     await expect.poll(() => page.locator('tr[data-kind]').count(), { timeout: 10_000 }).toBe(before)
+    await plot.click({ button: 'right' })
+    await expect.poll(() => page.locator('tr[data-timeline-focus]').count(), { timeout: 10_000 }).toBe(0)
   }, 60_000)
 
   it.skipIf(MODE === 'record')('opens the details column from the bash row and closes it', async () => {

+ 1 - 1
apps/web/tests/snapshots/navigation-panes/trajectory.expected.md

@@ -2,7 +2,7 @@
   - text: Trajectory
   - button "Collapse calls"
   - button "Collapse turns"
-- region "Trajectory timeline": Overview 9 timed events
+- region "Trajectory timeline"
 - table:
   - rowgroup:
     - row "SYSTEM, Initial System Prompt":

+ 7 - 1
packages/client/ui-trajectory/src/client/TrajectoryTable.module.css

@@ -76,7 +76,13 @@
 .table tbody tr:not([data-collapsed-summary]) {
   cursor: default;
   outline: none;
-  transition: background-color 120ms var(--ds-ease-in-out);
+  transition:
+    background-color 120ms var(--ds-ease-in-out),
+    opacity 120ms var(--ds-ease-in-out);
+}
+
+.table tbody tr[data-timeline-focus='outside'] {
+  opacity: 0.24;
 }
 
 .table tbody tr:not([data-collapsed-summary]):not([data-selected='true']):hover {

+ 13 - 1
packages/client/ui-trajectory/src/client/TrajectoryTable.tsx

@@ -1,6 +1,6 @@
 /** Turn-aware trajectory event ledger with a local record inspector. */
 
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
 import type { CSSProperties, ReactNode } from 'react'
 import {
   extractMarkdownPlainText, IconChevronRightOutline14, JsonTree, MarkdownText,
@@ -217,6 +217,10 @@ export interface TrajectoryTableProps {
   requestNumbers?: readonly TrajectoryRequestNumber[]
   /** Grouped records in display order. */
   turns: readonly TrajectoryTurnModel[]
+  /** Record indexes emphasized by the active timeline focus. */
+  timelineFocusIndexes?: ReadonlySet<number> | null
+  /** Report the record currently selected in the local inspector. */
+  onSelectedIndexChange?: (index: number | null) => void
   /** Turn ids whose rows after the first are folded into a summary. */
   collapsedTurns: ReadonlySet<number>
   /** Toggle one turn between folded and expanded. */
@@ -1357,6 +1361,8 @@ function OverviewSection({
 export function TrajectoryTable({
   requestNumbers: sessionRequestNumbers,
   turns,
+  timelineFocusIndexes = null,
+  onSelectedIndexChange,
   collapsedTurns,
   onToggleTurn,
   collapsedAssistants,
@@ -1370,6 +1376,9 @@ export function TrajectoryTable({
   const [toolRequestOffset, setToolRequestOffset] = useState<number | null>(null)
   const detailsResizeDrag = useRef<DetailsResizeDrag | null>(null)
   const tabHistory = useRef<Set<DetailTab>>(new Set(['overview']))
+  useEffect(() => {
+    onSelectedIndexChange?.(selectedIndex)
+  }, [onSelectedIndexChange, selectedIndex])
   const allRecords = flattenRecords(turns)
   const requestNumbers = indexRequestNumbers(allRecords, sessionRequestNumbers)
   const turnRecords = collapseTurnRecords(allRecords, collapsedTurns)
@@ -1570,6 +1579,9 @@ export function TrajectoryTable({
                   data-turn-end={record.turnEnd || undefined}
                   data-collapsed-summary={record.collapsedSummaryKind}
                   data-selected={!isCollapsedSummary && selectedIndex === record.cell.index || undefined}
+                  data-timeline-focus={isCollapsedSummary || timelineFocusIndexes === null
+                    ? undefined
+                    : timelineFocusIndexes.has(record.cell.index) ? 'inside' : 'outside'}
                   onClick={isRequestOnly
                     ? undefined
                     : isCollapsedSummary

+ 92 - 83
packages/client/ui-trajectory/src/client/TrajectoryTimeline.module.css

@@ -1,130 +1,150 @@
 .root {
   flex: none;
-  padding: 8px 16px 12px;
   border-bottom: 1px solid var(--dsw-alias-border-l2);
-  background: var(--dsw-alias-bg-layer-1);
   user-select: none;
 }
 
-.header {
-  display: flex;
-  align-items: center;
-  min-height: 24px;
-  gap: 8px;
+.plot {
+  display: grid;
+  grid-template-columns: 44px minmax(0, 1fr);
+  height: 50px;
+  overflow: hidden;
+  background: var(--dsw-alias-bg-layer-2);
 }
 
-.title {
-  color: var(--dsw-alias-label-primary);
+.labels {
+  position: relative;
+  border-right: 1px solid var(--dsw-alias-border-l1);
+  color: var(--dsw-alias-label-caption);
   font: var(--dsw-font-xs-13);
-  font-weight: 600;
+  font-size: 10px;
+  line-height: 1;
 }
 
-.summary {
-  flex: 1;
-  color: var(--dsw-alias-label-caption);
-  font: var(--dsw-font-xs-13);
+.labels span {
+  position: absolute;
+  right: 6px;
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  height: 8px;
+  text-align: right;
 }
 
-.clear {
-  flex: none;
-  padding: 2px 8px;
-  border: 0;
-  border-radius: 4px;
-  color: var(--dsw-alias-state-business-primary);
-  background: transparent;
-  font: var(--dsw-font-xs-13);
-  cursor: pointer;
+.labels span:nth-child(1) {
+  top: 7px;
 }
 
-.clear:hover {
-  background: var(--dsw-alias-interactive-bg-hover);
+.labels span:nth-child(2) {
+  top: 21px;
 }
 
-.clear:focus-visible {
-  outline: 1px solid var(--dsw-alias-state-business-primary);
-  outline-offset: 1px;
+.labels span:nth-child(3) {
+  top: 35px;
 }
 
-.plot {
+.track {
   position: relative;
-  height: 72px;
   overflow: hidden;
-  border: 1px solid var(--dsw-alias-border-l2);
-  border-radius: 4px;
-  background: var(--dsw-alias-bg-layer-2);
   cursor: crosshair;
   touch-action: none;
 }
 
-.plot:focus-visible {
-  outline: 1px solid var(--dsw-alias-state-business-primary);
-  outline-offset: 1px;
-}
-
-.ticks {
-  position: absolute;
-  inset: 0 8px auto;
-  height: 20px;
-  border-bottom: 1px solid var(--dsw-alias-border-l1);
-}
-
-.tick {
+.empty {
   position: absolute;
-  left: var(--trajectory-tick-left);
-  padding: 2px 4px;
-  transform: translateX(-50%);
-  white-space: nowrap;
+  top: 50%;
+  left: 50%;
+  transform: translate(-50%, -50%);
   color: var(--dsw-alias-label-caption);
   font: var(--dsw-font-xs-13);
 }
 
-.tick:first-child {
-  transform: none;
+.track:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: -1px;
 }
 
-.tick:last-child {
-  transform: translateX(-100%);
+.lanes {
+  position: absolute;
+  z-index: 2;
+  inset: 7px 0;
 }
 
-.tick::after {
+.turnBoundaries {
   position: absolute;
-  top: 20px;
-  bottom: -52px;
-  left: 50%;
-  width: 1px;
-  background: var(--dsw-alias-border-l1);
-  content: '';
+  z-index: 3;
+  inset: 0;
+  pointer-events: none;
 }
 
-.lanes {
+.turnBoundary {
   position: absolute;
-  inset: 24px 8px 6px;
+  top: 0;
+  bottom: 0;
+  left: var(--trajectory-turn-left);
+  width: 1px;
+  background: var(--dsw-alias-border-l2);
 }
 
 .span {
   position: absolute;
   top: calc(var(--trajectory-span-lane) * 14px);
-  left: var(--trajectory-span-left);
-  width: var(--trajectory-span-width);
+  left: calc(var(--trajectory-span-left) + 1px);
+  width: max(2px, calc(var(--trajectory-span-width) - 2px));
   height: 8px;
   min-width: 2px;
   border-radius: 1px;
-  background: var(--dsw-alias-label-tertiary);
-  opacity: 0.72;
+  background: var(--dsw-alias-label-secondary);
+  opacity: 0.78;
+}
+
+.span[data-timeline-span='user'] {
+  background: var(--dsw-alias-state-business-primary);
+}
+
+.span[data-timeline-span='context'] {
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-state-success-primary) 68%,
+    var(--dsw-alias-label-secondary)
+  );
+}
+
+.span[data-timeline-span='message'] {
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-brand-primary-new-colorprimary-new-color) 60%,
+    var(--dsw-alias-state-error-secondary)
+  );
 }
 
-.span[data-timeline-span='message'],
-.span[data-timeline-span='compacted'] {
-  background: var(--dsw-alias-brand-primary-new-colorprimary-new-color);
+.span[data-timeline-span='tool'] {
+  background: var(--dsw-alias-state-warn-label);
 }
 
-.span[data-timeline-span='tool'],
 .span[data-timeline-span='subtool'] {
-  background: var(--dsw-alias-state-business-primary);
+  background: color-mix(
+    in srgb,
+    var(--dsw-alias-state-warn-label) 62%,
+    var(--dsw-alias-label-tertiary)
+  );
+}
+
+.span[data-selected='false'] {
+  opacity: 0.2;
+}
+
+.span[data-current='true'] {
+  z-index: 1;
+  opacity: 1;
+  box-shadow:
+    0 0 0 1px var(--dsw-alias-bg-layer-2),
+    0 0 0 2px var(--dsw-alias-state-business-primary);
 }
 
 .selection {
   position: absolute;
+  z-index: 1;
   top: 0;
   bottom: 0;
   left: var(--trajectory-selection-left);
@@ -168,14 +188,3 @@
     transparent
   );
 }
-
-@media (max-width: 720px) {
-  .root {
-    padding-right: 12px;
-    padding-left: 12px;
-  }
-
-  .plot {
-    height: 64px;
-  }
-}

+ 172 - 92
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx

@@ -1,19 +1,19 @@
 /** Chrome-Network-style overview timeline for focusing the trajectory ledger. */
 
 import {
-  memo, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent,
+  memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
+  type PointerEvent, type WheelEvent,
 } from 'react'
 import type { TrajectoryTurnModel } from './layout.ts'
 import {
   deriveTrajectoryTimeline,
-  filterTrajectoryTimelineRange,
-  formatTimelineOffset,
+  type TrajectoryTimelineMode,
   type TrajectoryTimeRange,
 } from './timeline.ts'
 import css from './TrajectoryTimeline.module.css'
 
-const TICK_COUNT = 5
 const MINIMUM_DRAG_PX = 3
+const MINIMUM_ZOOM_OPERATIONS = 4
 
 interface FractionRange {
   start: number
@@ -23,8 +23,11 @@ interface FractionRange {
 /** Props for the fixed full-domain overview above the trajectory ledger. */
 export interface TrajectoryTimelineProps {
   turns: readonly TrajectoryTurnModel[]
+  mode: TrajectoryTimelineMode
   range: TrajectoryTimeRange | null
+  selectedIndex?: number | null
   onRangeChange: (range: TrajectoryTimeRange | null) => void
+  onRecordFocus?: (index: number) => void
 }
 
 function orderedRange(left: number, right: number): FractionRange {
@@ -46,33 +49,77 @@ function rangeFraction(
   )
 }
 
+function LaneLabels() {
+  return (
+    <div className={css.labels} aria-hidden="true">
+      <span>Input</span>
+      <span>Model</span>
+      <span>Tools</span>
+    </div>
+  )
+}
+
 /** Overview renderer with drag-to-filter and Escape/clear reset. */
 export const TrajectoryTimeline = memo(function TrajectoryTimeline({
   turns,
+  mode,
   range,
+  selectedIndex = null,
   onRangeChange,
+  onRecordFocus,
 }: TrajectoryTimelineProps) {
-  const model = useMemo(() => deriveTrajectoryTimeline(turns), [turns])
+  const model = useMemo(() => deriveTrajectoryTimeline(turns, mode), [mode, turns])
   const dragRef = useRef<{ pointerId: number; anchor: number; width: number } | null>(null)
   const [draft, setDraft] = useState<FractionRange | null>(null)
-  const domainDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
+  const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
+  useEffect(() => {
+    if (
+      model !== null
+      && range !== null
+      && (range.end < model.start || range.start > model.end)
+    ) {
+      onRangeChange(null)
+    }
+  }, [model, onRangeChange, range])
+  useEffect(() => {
+    if (model === null) return
+    setViewport(current =>
+      current !== null && (current.end < model.start || current.start > model.end)
+        ? null
+        : current)
+  }, [model])
+  const fullDuration = Math.max(1, (model?.end ?? 0) - (model?.start ?? 0))
+  const viewportDuration = Math.min(
+    fullDuration,
+    Math.max(1, (viewport?.end ?? 0) - (viewport?.start ?? 0)),
+  )
+  const viewportStart = model === null || viewport === null
+    ? model?.start ?? 0
+    : Math.min(
+      Math.max(viewport.start, model.start),
+      model.end - viewportDuration,
+    )
+  const domainDuration = viewport === null ? fullDuration : viewportDuration
+  const domainStart = viewport === null ? model?.start ?? 0 : viewportStart
   const committed = model === null || range === null
     ? null
-    : rangeFraction(range, model.start, domainDuration)
+    : rangeFraction(range, domainStart, domainDuration)
   const visibleRange = draft ?? committed
-  const focusedCount = useMemo(
-    () => range === null
-      ? model?.spans.length ?? 0
-      : deriveTrajectoryTimeline(filterTrajectoryTimelineRange(turns, range))?.spans.length ?? 0,
-    [model?.spans.length, range, turns],
-  )
+  const activeRange = draft === null
+    ? range
+    : {
+      start: domainStart + draft.start * domainDuration,
+      end: domainStart + draft.end * domainDuration,
+    }
 
   if (model === null) {
     return (
       <section className={css.root} aria-label="Trajectory timeline">
-        <div className={css.header}>
-          <span className={css.title}>Overview</span>
-          <span className={css.summary}>No timing data</span>
+        <div className={css.plot}>
+          <LaneLabels />
+          <div className={css.track}>
+            <span className={css.empty}>No timing data</span>
+          </div>
         </div>
       </section>
     )
@@ -85,8 +132,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
 
   const commit = (fraction: FractionRange) => {
     onRangeChange({
-      start: model.start + fraction.start * domainDuration,
-      end: model.start + fraction.end * domainDuration,
+      start: domainStart + fraction.start * domainDuration,
+      end: domainStart + fraction.end * domainDuration,
     })
   }
 
@@ -115,6 +162,17 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     setDraft(null)
     if ((selected.end - selected.start) * drag.width < MINIMUM_DRAG_PX) {
       onRangeChange(null)
+      const point = domainStart + selected.start * domainDuration
+      const nearest = model.spans.reduce((candidate, span) => {
+        const candidateDistance = point < candidate.start
+          ? candidate.start - point
+          : point > candidate.end ? point - candidate.end : 0
+        const spanDistance = point < span.start
+          ? span.start - point
+          : point > span.end ? point - span.end : 0
+        return spanDistance < candidateDistance ? span : candidate
+      })
+      onRecordFocus?.(nearest.index)
     } else {
       commit(selected)
     }
@@ -131,85 +189,107 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     setDraft(null)
   }
 
-  const ticks = Array.from({ length: TICK_COUNT }, (_, index) => {
-    const fraction = index / (TICK_COUNT - 1)
-    return {
-      fraction,
-      label: formatTimelineOffset(fraction * domainDuration),
+  const onWheel = (event: WheelEvent<HTMLDivElement>) => {
+    event.preventDefault()
+    const rect = event.currentTarget.getBoundingClientRect()
+    const anchorFraction =
+      clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
+    const nextDuration = Math.min(
+      fullDuration,
+      Math.max(
+        Math.min(mode === 'actual' ? 20 : MINIMUM_ZOOM_OPERATIONS, fullDuration),
+        domainDuration * Math.exp(event.deltaY * 0.0015),
+      ),
+    )
+    if (nextDuration >= fullDuration * 0.999) {
+      setViewport(null)
+      return
     }
-  })
-  const summary = range === null
-    ? `${model.spans.length} timed events`
-    : `${focusedCount} of ${model.spans.length} events · ${formatTimelineOffset(range.start - model.start)}–${formatTimelineOffset(range.end - model.start)}`
+    const anchorTime = domainStart + anchorFraction * domainDuration
+    const nextStart = Math.min(
+      Math.max(anchorTime - anchorFraction * nextDuration, model.start),
+      model.end - nextDuration,
+    )
+    setViewport({ start: nextStart, end: nextStart + nextDuration })
+  }
 
   return (
     <section className={css.root} aria-label="Trajectory timeline">
-      <div className={css.header}>
-        <span className={css.title}>Overview</span>
-        <span className={css.summary} aria-live="polite">{summary}</span>
-        {range !== null && (
-          <button
-            className={css.clear}
-            type="button"
-            onClick={() => {
-              onRangeChange(null)
-            }}
-          >
-            Clear selection
-          </button>
-        )}
-      </div>
-      <div
-        className={css.plot}
-        aria-label="Timeline overview; drag horizontally to filter events"
-        tabIndex={0}
-        onKeyDown={onKeyDown}
-        onPointerDown={onPointerDown}
-        onPointerMove={onPointerMove}
-        onPointerUp={onPointerEnd}
-        onPointerCancel={onPointerCancel}
-      >
-        <div className={css.ticks} aria-hidden="true">
-          {ticks.map(tick => (
-            <span
-              className={css.tick}
-              key={tick.fraction}
-              style={{ '--trajectory-tick-left': `${tick.fraction * 100}%` } as CSSProperties}
-            >
-              {tick.label}
-            </span>
-          ))}
-        </div>
-        <div className={css.lanes} aria-hidden="true">
-          {model.spans.map((span) => {
-            const left = (span.start - model.start) / domainDuration
-            const width = (span.end - span.start) / domainDuration
-            return (
-              <span
-                className={css.span}
-                data-timeline-span={span.kind}
-                key={span.index}
-                title={`${span.label} · ${formatTimelineOffset(span.end - span.start)}`}
-                style={{
-                  '--trajectory-span-left': `${left * 100}%`,
-                  '--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
-                  '--trajectory-span-lane': span.lane,
-                } as CSSProperties}
-              />
-            )
-          })}
+      <div className={css.plot}>
+        <LaneLabels />
+        <div
+          className={css.track}
+          aria-label="Timeline overview; drag horizontally to focus events"
+          tabIndex={0}
+          onKeyDown={onKeyDown}
+          onPointerDown={onPointerDown}
+          onPointerMove={onPointerMove}
+          onPointerUp={onPointerEnd}
+          onPointerCancel={onPointerCancel}
+          onWheel={onWheel}
+          onContextMenu={(event) => {
+            event.preventDefault()
+            onRangeChange(null)
+            setViewport(null)
+          }}
+        >
+          {visibleRange !== null && (
+            <div
+              className={css.selection}
+              data-dragging={draft === null ? undefined : 'true'}
+              aria-hidden="true"
+              style={{
+                '--trajectory-selection-left': `${visibleRange.start * 100}%`,
+                '--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
+              } as CSSProperties}
+            />
+          )}
+          <div className={css.turnBoundaries} aria-hidden="true">
+            {model.turnBoundaries
+              .slice(1)
+              .filter(boundary =>
+                boundary.time >= domainStart
+                && boundary.time <= domainStart + domainDuration)
+              .map(boundary => (
+                <span
+                  className={css.turnBoundary}
+                  data-turn={boundary.turn}
+                  key={boundary.turn}
+                  style={{
+                    '--trajectory-turn-left':
+                      `${(boundary.time - domainStart) / domainDuration * 100}%`,
+                  } as CSSProperties}
+                />
+              ))}
+          </div>
+          <div className={css.lanes} aria-hidden="true">
+            {model.spans
+              .filter(span => span.end >= domainStart && span.start <= domainStart + domainDuration)
+              .map((span) => {
+                const left = (span.start - domainStart) / domainDuration
+                const width = (span.end - span.start) / domainDuration
+                return (
+                  <span
+                    className={css.span}
+                    data-timeline-span={span.kind}
+                    data-current={span.index === selectedIndex || undefined}
+                    data-selected={activeRange === null
+                      ? undefined
+                      : span.start <= activeRange.end && span.end >= activeRange.start
+                        ? 'true'
+                        : 'false'}
+                    key={span.index}
+                    title={span.label}
+                    style={{
+                      '--trajectory-span-left': `${left * 100}%`,
+                      '--trajectory-span-width': `${Math.max(width * 100, 0.35)}%`,
+                      '--trajectory-span-lane': span.lane,
+                    } as CSSProperties}
+                  />
+                )
+              })}
+          </div>
         </div>
-        {visibleRange !== null && (
-          <div
-            className={css.selection}
-            data-dragging={draft === null ? undefined : 'true'}
-            aria-hidden="true"
-            style={{
-              '--trajectory-selection-left': `${visibleRange.start * 100}%`,
-              '--trajectory-selection-width': `${(visibleRange.end - visibleRange.start) * 100}%`,
-            } as CSSProperties}
-          />
-        )}
       </div>
     </section>
   )

+ 55 - 0
packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css

@@ -39,6 +39,61 @@
   gap: 2px;
 }
 
+.modeSwitch {
+  display: inline-flex;
+  flex: none;
+  align-items: center;
+  height: 24px;
+  margin-right: 5px;
+  padding: 0 7px;
+  gap: 6px;
+  border: 0;
+  border-radius: 4px;
+  color: var(--dsw-alias-label-tertiary);
+  background: transparent;
+  cursor: pointer;
+  font: var(--dsw-font-xs-13);
+}
+
+.modeSwitch:hover {
+  color: var(--dsw-alias-label-primary);
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.modeSwitch:focus-visible {
+  outline: 1px solid var(--dsw-alias-state-business-primary);
+  outline-offset: 2px;
+}
+
+.modeTrack {
+  position: relative;
+  display: inline-block;
+  width: 26px;
+  height: 14px;
+  border-radius: 7px;
+  background: var(--dsw-alias-border-l2);
+  transition: background-color 120ms var(--ds-ease-in-out);
+}
+
+.modeThumb {
+  position: absolute;
+  top: 2px;
+  left: 2px;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: var(--dsw-alias-bg-layer-1);
+  transition: transform 120ms var(--ds-ease-in-out);
+}
+
+.modeSwitch[aria-checked='true'] .modeTrack {
+  background: var(--dsw-alias-state-business-primary);
+}
+
+.modeSwitch[aria-checked='true'] .modeThumb {
+  transform: translateX(12px);
+}
+
 .action {
   display: inline-flex;
   flex: none;

+ 18 - 0
packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx

@@ -3,6 +3,10 @@
 import css from './TrajectoryToolbar.module.css'
 
 export interface TrajectoryToolbarProps {
+  /** Whether the timeline uses recorded durations instead of equal-width operations. */
+  actualTime: boolean
+  /** Select the timeline's recorded-time or equal-width projection. */
+  onActualTimeChange: (actualTime: boolean) => void
   /** Number of turns containing more than one row. */
   collapsibleTurns: number
   /** Whether every collapsible turn is currently folded. */
@@ -23,6 +27,8 @@ export interface TrajectoryToolbarProps {
  * @returns the toolbar element.
  */
 export function TrajectoryToolbar({
+  actualTime,
+  onActualTimeChange,
   collapsibleTurns,
   allTurnsCollapsed,
   onToggleAllTurns,
@@ -37,6 +43,18 @@ export function TrajectoryToolbar({
           <span className={css.title}>Trajectory</span>
         </div>
         <div className={css.actions}>
+          <button
+            type="button"
+            className={css.modeSwitch}
+            role="switch"
+            aria-checked={actualTime}
+            onClick={() => { onActualTimeChange(!actualTime) }}
+          >
+            <span>Actual time</span>
+            <span className={css.modeTrack} aria-hidden="true">
+              <span className={css.modeThumb} />
+            </span>
+          </button>
           <button
             type="button"
             className={css.action}

+ 51 - 11
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -17,7 +17,9 @@ import { TrajectoryToolbar } from './TrajectoryToolbar.tsx'
 import { TrajectoryTimeline } from './TrajectoryTimeline.tsx'
 import { deriveTrajectoryLayout } from './layout.ts'
 import {
-  filterTrajectoryTimelineRange, type TrajectoryTimeRange,
+  trajectoryTimelineFocusIndexes,
+  type TrajectoryTimelineMode,
+  type TrajectoryTimeRange,
 } from './timeline.ts'
 import css from './views.module.css'
 
@@ -81,6 +83,9 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
     branchId: number
     range: TrajectoryTimeRange
   } | null>(null)
+  const [timelineMode, setTimelineMode] = useState<TrajectoryTimelineMode>('sequence')
+  const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
+  const ledgerRef = useRef<HTMLDivElement>(null)
   const nodes = useSession(s => s.nodes)
   const inspection = useSession(s => s.inspection)
   const hasMore = useSession(s => s.hasMore)
@@ -260,12 +265,33 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
   const timelineRange = timelineSelection?.branchId === currentBranch.id
     ? timelineSelection.range
     : null
-  const focusedTurns = useMemo(
-    () => filterTrajectoryTimelineRange(turns, timelineRange),
-    [timelineRange, turns],
+  const timelineFocusIndexes = useMemo(
+    () => timelineRange === null
+      ? null
+      : trajectoryTimelineFocusIndexes(turns, timelineRange, timelineMode),
+    [timelineMode, timelineRange, turns],
   )
+  useEffect(() => {
+    if (timelineFocusIndexes === null || timelineFocusIndexes.size === 0) return
+    const ledger = ledgerRef.current
+    if (ledger === null) return
+    const focusedRows = [
+      ...ledger.querySelectorAll<HTMLElement>('tr[data-timeline-focus="inside"]'),
+    ]
+    const first = focusedRows.at(0)
+    const last = focusedRows.at(-1)
+    if (first === undefined || last === undefined) return
+    const focusHeight =
+      last.getBoundingClientRect().bottom - first.getBoundingClientRect().top
+    if (focusHeight > ledger.clientHeight) {
+      first.scrollIntoView({ behavior: 'smooth', block: 'start' })
+      return
+    }
+    focusedRows[Math.floor((focusedRows.length - 1) / 2)]
+      ?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+  }, [timelineFocusIndexes])
   const collapsibleTurnIds = useMemo(
-    () => focusedTurns
+    () => turns
       .filter(turn =>
         turn.groups.reduce(
           (count, group) =>
@@ -274,13 +300,13 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
           0,
         ) > 1)
       .map(turn => turn.turn),
-    [focusedTurns],
+    [turns],
   )
   const allTurnsCollapsed = collapsibleTurnIds.length > 0
     && collapsibleTurnIds.every(turn => collapsedTurns.has(turn))
   const collapsibleAssistantIds = useMemo(() => {
     const ids: number[] = []
-    for (const turn of focusedTurns) {
+    for (const turn of turns) {
       const cells = turn.groups.flatMap(group => group.cells)
       for (let i = 0; i < cells.length; i++) {
         const cell = cells[i]
@@ -290,7 +316,7 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
       }
     }
     return ids
-  }, [focusedTurns])
+  }, [turns])
   const allAssistantsCollapsed = collapsibleAssistantIds.length > 0
     && collapsibleAssistantIds.every(index => collapsedAssistants.has(index))
 
@@ -339,6 +365,11 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
   return (
     <div className={css.root}>
       <TrajectoryToolbar
+        actualTime={timelineMode === 'actual'}
+        onActualTimeChange={(actualTime) => {
+          setTimelineMode(actualTime ? 'actual' : 'sequence')
+          setTimelineSelection(null)
+        }}
         collapsibleTurns={collapsibleTurnIds.length}
         allTurnsCollapsed={allTurnsCollapsed}
         onToggleAllTurns={toggleAllTurns}
@@ -348,16 +379,25 @@ export function TrajectoryView({ useSession, loadAllHistory }: ConvViewProps & T
       />
       <TrajectoryTimeline
         turns={turns}
+        mode={timelineMode}
         range={timelineRange}
+        selectedIndex={selectedTimelineIndex}
         onRangeChange={(range) => {
           setTimelineSelection(range === null ? null : { branchId: currentBranch.id, range })
         }}
+        onRecordFocus={(index) => {
+          ledgerRef.current
+            ?.querySelector<HTMLElement>(`tr[data-record-index="${index}"]`)
+            ?.scrollIntoView({ behavior: 'smooth', block: 'center' })
+        }}
       />
-      <div className={css.ledger}>
+      <div ref={ledgerRef} className={css.ledger}>
         <TrajectoryTable
-          key={`${currentBranch.id}:${timelineRange?.start ?? 'all'}:${timelineRange?.end ?? 'all'}`}
+          key={currentBranch.id}
           requestNumbers={requestNumbers}
-          turns={focusedTurns}
+          turns={turns}
+          timelineFocusIndexes={timelineFocusIndexes}
+          onSelectedIndexChange={setSelectedTimelineIndex}
           collapsedTurns={collapsedTurns}
           onToggleTurn={toggleTurn}
           collapsedAssistants={collapsedAssistants}

+ 103 - 46
packages/client/ui-trajectory/src/client/timeline.ts

@@ -1,15 +1,18 @@
-/** Time-domain projection and filtering for the trajectory overview. */
+/** Operation-sequence and recorded-time projections for the trajectory overview. */
 
-import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
 import type { TrajectoryTurnModel } from './layout.ts'
+import type { TrajectoryCellKind, TrajectoryCellProps } from './trajectory-record.ts'
+
+/** Horizontal projection used by the trajectory timeline. */
+export type TrajectoryTimelineMode = 'sequence' | 'actual'
 
-/** Inclusive absolute-time selection in Unix epoch milliseconds. */
+/** Inclusive selection in the active timeline projection's domain. */
 export interface TrajectoryTimeRange {
   start: number
   end: number
 }
 
-/** One timed ledger record projected into the overview. */
+/** One ledger record projected into the active timeline domain. */
 export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
   index: number
   kind: TrajectoryCellKind
@@ -17,9 +20,22 @@ export interface TrajectoryTimelineSpan extends TrajectoryTimeRange {
   lane: number
 }
 
+/** One turn boundary in the active timeline domain. */
+export interface TrajectoryTimelineTurnBoundary {
+  turn: number
+  time: number
+}
+
 /** Full-domain model used by the overview. */
 export interface TrajectoryTimelineModel extends TrajectoryTimeRange {
   spans: readonly TrajectoryTimelineSpan[]
+  turnBoundaries: readonly TrajectoryTimelineTurnBoundary[]
+}
+
+function laneFor(kind: TrajectoryCellKind): number {
+  if (kind === 'tool' || kind === 'subtool') return 2
+  if (kind === 'message' || kind === 'compacted') return 1
+  return 0
 }
 
 function finite(value: number | null | undefined): value is number {
@@ -34,22 +50,58 @@ function cellRange(cell: TrajectoryCellProps): TrajectoryTimeRange | null {
   return { start: cell.startedAt, end: cell.startedAt + durationMs }
 }
 
-function laneFor(kind: TrajectoryCellKind): number {
-  if (kind === 'tool' || kind === 'subtool') return 2
-  if (kind === 'message' || kind === 'compacted') return 1
-  return 0
-}
-
 /**
- * Project every visible timed record into a stable three-lane overview.
+ * Project every visible record into a stable three-lane timeline.
  * @param turns - Unfiltered trajectory layout.
- * @returns Timeline model, or `null` when no record carries a start time.
+ * @param mode - Equal-width operation sequence or recorded wall-clock timing.
+ * @returns Timeline model, or `null` when no record is visible.
  */
 export function deriveTrajectoryTimeline(
   turns: readonly TrajectoryTurnModel[],
+  mode: TrajectoryTimelineMode = 'sequence',
 ): TrajectoryTimelineModel | null {
-  const spans = turns.flatMap(turn =>
-    turn.groups.flatMap(group =>
+  if (mode === 'actual') return deriveActualTimeline(turns)
+  const spans: TrajectoryTimelineSpan[] = []
+  const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
+
+  for (const turn of turns) {
+    const cells = turn.groups.flatMap(group =>
+      group.cells.filter(cell => cell.requestOnly !== true),
+    )
+    if (cells.length === 0) continue
+    turnBoundaries.push({
+      turn: turn.turn,
+      time: spans.length,
+    })
+    spans.push(...cells.map((cell, offset): TrajectoryTimelineSpan => ({
+      start: spans.length + offset,
+      end: spans.length + offset + 1,
+      index: cell.index,
+      kind: cell.kind,
+      label: cell.text,
+      lane: laneFor(cell.kind),
+    })))
+  }
+
+  if (spans.length === 0) return null
+  return {
+    start: 0,
+    end: spans.length,
+    spans,
+    turnBoundaries,
+  }
+}
+
+function deriveActualTimeline(
+  turns: readonly TrajectoryTurnModel[],
+): TrajectoryTimelineModel | null {
+  const spans: TrajectoryTimelineSpan[] = []
+  const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
+  let removedUserIdle = 0
+  let previousTurnEnd: number | null = null
+
+  for (const turn of turns) {
+    const rawSpans = turn.groups.flatMap(group =>
       group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
         if (cell.requestOnly === true) return []
         const range = cellRange(cell)
@@ -63,48 +115,53 @@ export function deriveTrajectoryTimeline(
             lane: laneFor(cell.kind),
           }]
       }),
-    ),
-  )
+    )
+    if (rawSpans.length === 0) continue
+
+    const turnStart = Math.min(...rawSpans.map(span => span.start))
+    const turnEnd = Math.max(...rawSpans.map(span => span.end))
+    if (previousTurnEnd !== null) {
+      removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
+    }
+    spans.push(...rawSpans.map(span => ({
+      ...span,
+      start: span.start - removedUserIdle,
+      end: span.end - removedUserIdle,
+    })))
+    turnBoundaries.push({
+      turn: turn.turn,
+      time: turnStart - removedUserIdle,
+    })
+    previousTurnEnd = previousTurnEnd === null
+      ? turnEnd
+      : Math.max(previousTurnEnd, turnEnd)
+  }
+
   if (spans.length === 0) return null
   return {
     start: Math.min(...spans.map(span => span.start)),
     end: Math.max(...spans.map(span => span.end)),
     spans,
+    turnBoundaries,
   }
 }
 
-function overlaps(cell: TrajectoryCellProps, range: TrajectoryTimeRange): boolean {
-  const timed = cellRange(cell)
-  return timed !== null && timed.start <= range.end && timed.end >= range.start
-}
-
 /**
- * Keep records active at any point inside an inclusive selected interval.
+ * Identify records active at any point inside an inclusive selected interval.
  * @param turns - Unfiltered trajectory layout.
- * @param range - Absolute selected interval, or `null` for the full ledger.
- * @returns A layout retaining original turn, group, and record identities.
+ * @param range - Selected interval in the active projection.
+ * @param mode - Equal-width operation sequence or recorded wall-clock timing.
+ * @returns Record indexes inside the focus interval.
  */
-export function filterTrajectoryTimelineRange(
+export function trajectoryTimelineFocusIndexes(
   turns: readonly TrajectoryTurnModel[],
-  range: TrajectoryTimeRange | null,
-): readonly TrajectoryTurnModel[] {
-  if (range === null) return turns
-  return turns.flatMap((turn): TrajectoryTurnModel[] => {
-    const groups = turn.groups.flatMap((group) => {
-      const cells = group.cells.filter(cell => overlaps(cell, range))
-      return cells.length === 0 ? [] : [{ ...group, cells }]
-    })
-    return groups.length === 0 ? [] : [{ ...turn, groups }]
-  })
-}
-
-/**
- * Format a relative timeline offset with a compact unit.
- * @param milliseconds - Non-negative relative offset.
- * @returns Millisecond or second label.
- */
-export function formatTimelineOffset(milliseconds: number): string {
-  if (milliseconds < 1_000) return `${Math.round(milliseconds)} ms`
-  const seconds = milliseconds / 1_000
-  return seconds >= 10 ? `${Math.round(seconds)} s` : `${seconds.toFixed(1)} s`
+  range: TrajectoryTimeRange,
+  mode: TrajectoryTimelineMode = 'sequence',
+): ReadonlySet<number> {
+  const model = deriveTrajectoryTimeline(turns, mode)
+  return new Set(
+    model?.spans
+      .filter(span => span.start <= range.end && span.end >= range.start)
+      .map(span => span.index),
+  )
 }

+ 53 - 25
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -19,18 +19,13 @@ import type {
   ConversationSnapshot, RequestView, SessionId, SessionListState, WorkspaceListState,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client'
-// Export discipline: packages/client/AGENTS.md.
 import { ConversationSession, type ConversationSessionProps } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx'
 import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts'
 import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client'
-import type { TrajectoryTurnModel } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/layout.ts'
-import { TrajectoryView } from '@deepseek-ai/dsh-client-ui-trajectory/src/client/TrajectoryView.tsx'
-import {
-  deriveTrajectoryTimeline,
-  filterTrajectoryTimelineRange,
-  formatTimelineOffset,
-} from '@deepseek-ai/dsh-client-ui-trajectory/src/client/timeline.ts'
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory'
+import type { TrajectoryTurnModel } from '../src/client/layout.ts'
+import { TrajectoryView } from '../src/client/TrajectoryView.tsx'
+import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
 
 const SID = 's1' as SessionId
 afterEach(cleanup)
@@ -229,11 +224,11 @@ describe('tab switching in ConversationRoot', () => {
     expect(screen.queryByRole('complementary', { name: 'Event details' })).toBeNull()
   })
 
-  it('dragging the overview focuses overlapping records and clear restores the ledger', async () => {
+  it('dragging the overview focuses overlapping records without filtering the ledger', async () => {
     const b = await bench()
     mount(b.slots)
     fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
-    const plot = screen.getByLabelText('Timeline overview; drag horizontally to filter events')
+    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: () => ({}),
@@ -242,10 +237,11 @@ describe('tab switching in ConversationRoot', () => {
     fireEvent.pointerMove(plot, { clientX: 95, pointerId: 1 })
     fireEvent.pointerUp(plot, { clientX: 95, pointerId: 1 })
 
-    expect(screen.queryByRole('row', { name: /USER/ })).toBeNull()
-    expect(screen.getByRole('button', { name: 'Clear selection' })).toBeTruthy()
-    fireEvent.click(screen.getByRole('button', { name: 'Clear selection' }))
-    expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy()
+    expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
+      .toBe('outside')
+    fireEvent.contextMenu(plot)
+    expect(screen.getByRole('row', { name: /USER/ }).getAttribute('data-timeline-focus'))
+      .toBeNull()
   })
 
   it('empty window keeps the toolbar and reports no timing data', async () => {
@@ -272,25 +268,57 @@ describe('timeline projection', () => {
     }],
   }] satisfies readonly TrajectoryTurnModel[]
 
-  it('uses real start/duration timing and stable semantic lanes', () => {
+  it('uses equal-width operation slots and stable semantic lanes', () => {
     expect(deriveTrajectoryTimeline(turns)).toEqual({
-      start: 1_000,
-      end: 3_000,
+      start: 0,
+      end: 3,
       spans: [
         {
-          index: 1, kind: 'message', label: 'assistant', lane: 1, start: 1_000, end: 2_000,
+          index: 1, kind: 'message', label: 'assistant', lane: 1, start: 0, end: 1,
         },
-        { index: 2, kind: 'tool', label: 'bash', lane: 2, start: 2_000, end: 3_000 },
+        { index: 2, kind: 'tool', label: 'bash', lane: 2, start: 1, end: 2 },
+        { index: 3, kind: 'user', label: 'unknown', lane: 0, start: 2, end: 3 },
       ],
+      turnBoundaries: [{ turn: 1, time: 0 }],
     })
-    expect(formatTimelineOffset(999)).toBe('999 ms')
-    expect(formatTimelineOffset(1_500)).toBe('1.5 s')
   })
 
-  it('filters inclusively and drops records without known timing', () => {
-    const focused = filterTrajectoryTimelineRange(turns, { start: 2_000, end: 2_000 })
-    expect(focused[0]?.groups[0]?.cells.map(cell => cell.index)).toEqual([1, 2])
-    expect(filterTrajectoryTimelineRange(turns, null)).toBe(turns)
+  it('ignores durations and idle gaps while retaining turn boundaries', () => {
+    const separatedTurns = [
+      {
+        turn: 1,
+        groups: [{
+          title: 'Step 1',
+          cells: [
+            { index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
+            { index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
+          ],
+        }],
+      },
+      {
+        turn: 2,
+        groups: [{
+          title: 'Step 1',
+          cells: [
+            { index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
+          ],
+        }],
+      },
+    ] satisfies readonly TrajectoryTurnModel[]
+
+    expect(deriveTrajectoryTimeline(separatedTurns)).toMatchObject({
+      start: 0,
+      end: 3,
+      spans: [
+        { index: 1, start: 0, end: 1 },
+        { index: 2, start: 1, end: 2 },
+        { index: 3, start: 2, end: 3 },
+      ],
+      turnBoundaries: [
+        { turn: 1, time: 0 },
+        { turn: 2, time: 2 },
+      ],
+    })
   })
 
   it('empty inputs produce no model and the standalone view reports its empty form', () => {