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

Merge pull request #1032 from deepseek-harness/xtr/trajectory-timeline-polish

fix(trajectory): polish timeline timing and layering
Tianyi Cui 1 месяц назад
Родитель
Сommit
e1c6fc3db4

+ 10 - 3
apps/web/tests/navigation-panes.e2e.ts

@@ -147,14 +147,21 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
     await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
     await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
     await page.locator('tr[data-kind="tool"]').first().click()
-    await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
+    const details = page.getByRole('complementary', { name: 'Event details' })
+    await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
+    await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
+    const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
+      heading: getComputedStyle(heading).backgroundColor,
+      panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
+    }))
+    expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
+    await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
     await page.getByRole('tab', { name: 'Result' }).click()
     await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
     const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
       .split(SEED_ID).join('{{seededId}}')
     await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
-    await page.getByRole('complementary', { name: 'Event details' })
-      .getByRole('button', { name: 'Close details' }).click()
+    await details.getByRole('button', { name: 'Close details' }).click()
   }, 60_000)
 
   it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {

+ 1 - 0
apps/web/vite.config.ts

@@ -22,6 +22,7 @@ export default defineConfig({
       { find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
+      { find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
       { find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
     ],
   },

+ 2 - 1
docs/module-graph.md

@@ -316,6 +316,7 @@ flowchart TD
   pkg_client_ui_settings --> pkg_client_ui_primitives
   pkg_client_ui_settings --> pkg_client_ui_slots
   pkg_client_ui_settings --> pkg_invariants
+  pkg_client_ui_trajectory --> pkg_client_runtime
   pkg_client_ui_trajectory --> pkg_client_ui_primitives
   pkg_client_ui_trajectory --> pkg_invariants
   pkg_credentials --> pkg_brand
@@ -1082,7 +1083,7 @@ flowchart TD
 | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
 | [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
 | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
-| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
+| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
 | [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
 | [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
 | [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |

+ 2 - 0
packages/client/ui-trajectory/package.json

@@ -24,6 +24,7 @@
   },
   "dshClient": {
     "inject": [
+      "@deepseek-ai/dsh-client-runtime",
       "@deepseek-ai/dsh-client-ui-conversation"
     ],
     "platform": "web"
@@ -37,6 +38,7 @@
     "diff": "^9.0.0"
   },
   "peerDependencies": {
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
     "@deepseek-ai/dsh-invariants": "^0.0.1",
     "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
     "cordis": "^4.0.0-rc.7",

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

@@ -321,6 +321,13 @@
   width: 76px;
 }
 
+.kindSlot :global([role='tooltip']) {
+  border: 1px solid var(--dsw-alias-border-l2);
+  background: var(--dsw-alias-bg-layer-2);
+  box-shadow: var(--dsw-shadow-lv2);
+  color: var(--dsw-alias-label-primary);
+}
+
 .content {
   padding-left: 4px !important;
   color: var(--dsw-alias-label-primary);
@@ -828,6 +835,7 @@
   flex: 1;
   min-height: 0;
   overflow: auto;
+  scrollbar-gutter: stable;
 }
 
 .detailBodySummary {
@@ -989,7 +997,7 @@
   margin: 0;
   padding: 0 0 3px 14px;
   color: var(--dsw-alias-label-secondary);
-  background: var(--dsw-alias-bg-base);
+  background: var(--dsw-alias-bg-layer-1);
   font: var(--dsw-font-xs-strong-13);
   user-select: none;
 }

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

@@ -1906,7 +1906,10 @@ export function TrajectoryTable({
                         <span
                           className={css.kindSlot}
                         >
-                          <Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
+                          <Tooltip
+                            label={KIND_LABEL[record.cell.kind]}
+                            side="right"
+                          >
                             <span
                               className={`${css.kindTag} ${
                                 record.cell.kind === 'system'

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

@@ -1,6 +1,6 @@
 .root {
   position: relative;
-  z-index: 0;
+  z-index: 1;
   isolation: isolate;
   flex: none;
   border-bottom: 1px solid var(--dsw-alias-border-l2);

+ 49 - 34
packages/client/ui-trajectory/src/client/TrajectoryTimeline.tsx

@@ -2,7 +2,7 @@
 
 import {
   memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
-  type PointerEvent, type WheelEvent,
+  type PointerEvent,
 } from 'react'
 import type { TrajectoryTurnModel } from './layout.ts'
 import {
@@ -70,11 +70,17 @@ function rangeFraction(
   range: TrajectoryTimeRange,
   start: number,
   duration: number,
+  minimum: number,
+  maximum: number,
 ): FractionRange {
-  return orderedRange(
-    clampFraction((range.start - start) / duration),
-    clampFraction((range.end - start) / duration),
+  const bounded = orderedRange(
+    Math.min(maximum, Math.max(minimum, range.start)),
+    Math.min(maximum, Math.max(minimum, range.end)),
   )
+  return {
+    start: (bounded.start - start) / duration,
+    end: (bounded.end - start) / duration,
+  }
 }
 
 function LaneLabels() {
@@ -117,6 +123,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     anchorClientX: number
     recordIndex: number | null
   } | null>(null)
+  const rootRef = useRef<HTMLElement | null>(null)
+  const trackRef = useRef<HTMLDivElement | null>(null)
   const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
   const [hover, setHover] = useState<HoverPoint | null>(null)
   const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
@@ -183,16 +191,48 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     } as CSSProperties
   const committed = model === null || range === null
     ? null
-    : rangeFraction(range, domainStart, domainDuration)
+    : rangeFraction(range, domainStart, domainDuration, model.start, model.end)
   const draftFraction = model === null || draft === null
     ? null
-    : rangeFraction(draft, domainStart, domainDuration)
+    : rangeFraction(draft, domainStart, domainDuration, model.start, model.end)
   const visibleRange = draftFraction ?? committed
   const activeRange = draft ?? range
+  useEffect(() => {
+    const root = rootRef.current
+    if (root === null) return
+    const onWheel = (event: globalThis.WheelEvent): void => {
+      event.preventDefault()
+      const track = trackRef.current
+      if (track === null || model === null) return
+      setAnimateViewport(false)
+      const rect = track.getBoundingClientRect()
+      const anchorFraction =
+        clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
+      const nextDuration = Math.min(
+        fullDuration,
+        Math.max(
+          Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
+          domainDuration * Math.exp(event.deltaY * 0.0015),
+        ),
+      )
+      if (nextDuration >= fullDuration * 0.999) {
+        setViewport(null)
+        return
+      }
+      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 })
+    }
+    root.addEventListener('wheel', onWheel, { passive: false })
+    return () => { root.removeEventListener('wheel', onWheel) }
+  }, [domainDuration, domainStart, fullDuration, mode, model])
 
   if (model === null) {
     return (
-      <section className={css.root} aria-label="Trajectory timeline">
+      <section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
         <div className={css.plot}>
           <LaneLabels />
           <div className={css.track}>
@@ -339,36 +379,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
     setHover(null)
   }
 
-  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))
-    const nextDuration = Math.min(
-      fullDuration,
-      Math.max(
-        Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
-        domainDuration * Math.exp(event.deltaY * 0.0015),
-      ),
-    )
-    if (nextDuration >= fullDuration * 0.999) {
-      setViewport(null)
-      return
-    }
-    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">
+    <section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
       <div className={css.plot}>
         <LaneLabels />
         <div
+          ref={trackRef}
           className={css.track}
           aria-label="Timeline overview; drag horizontally to focus events"
           tabIndex={0}
@@ -384,7 +400,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
             event.preventDefault()
             onRangeChange(null)
           }}
-          onWheel={onWheel}
           onContextMenu={(event) => {
             event.preventDefault()
             setAnimateViewport(false)

+ 1 - 6
packages/client/ui-trajectory/src/client/TrajectoryToolbar.module.css

@@ -149,7 +149,7 @@
   font: var(--dsw-font-xxs-12);
 }
 
-.action:hover:not(:disabled) {
+.action:hover {
   color: var(--dsw-alias-label-primary);
   background: var(--dsw-alias-interactive-bg-hover);
 }
@@ -159,11 +159,6 @@
   outline-offset: 1px;
 }
 
-.action:disabled {
-  color: var(--dsw-alias-label-dimmed);
-  cursor: not-allowed;
-}
-
 .actionIcon {
   color: var(--dsw-alias-label-tertiary);
   font: 14px/14px var(--ds-font-family-code);

+ 1 - 9
packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx

@@ -8,18 +8,14 @@ export interface TrajectoryToolbarProps {
   actualDuration: boolean
   /** Select recorded-duration or equal-width blocks. */
   onActualDurationChange: (actualDuration: boolean) => void
-  /** Whether recorded timing retains idle gaps between user turns. */
+  /** Whether recorded timing retains idle gaps between operations. */
   actualTime: boolean
   /** Select complete wall-clock timing or idle-compressed timing. */
   onActualTimeChange: (actualTime: boolean) => void
-  /** Number of turns containing more than one row. */
-  collapsibleTurns: number
   /** Whether every collapsible turn is currently folded. */
   allTurnsCollapsed: boolean
   /** Fold or expand every collapsible turn. */
   onToggleAllTurns: () => void
-  /** Number of assistant messages followed by tool calls. */
-  collapsibleAssistants: number
   /** Whether every collapsible assistant's tool calls are currently folded. */
   allAssistantsCollapsed: boolean
   /** Fold or expand tool calls under every collapsible assistant. */
@@ -40,10 +36,8 @@ export function TrajectoryToolbar({
   onActualDurationChange,
   actualTime,
   onActualTimeChange,
-  collapsibleTurns,
   allTurnsCollapsed,
   onToggleAllTurns,
-  collapsibleAssistants,
   allAssistantsCollapsed,
   onToggleAllAssistants,
   searchQuery,
@@ -91,7 +85,6 @@ export function TrajectoryToolbar({
             aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
             aria-pressed={allTurnsCollapsed}
             title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
-            disabled={collapsibleTurns === 0}
             onClick={onToggleAllTurns}
           >
             <span className={css.actionIcon} aria-hidden="true">
@@ -105,7 +98,6 @@ export function TrajectoryToolbar({
             aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
             aria-pressed={allAssistantsCollapsed}
             title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
-            disabled={collapsibleAssistants === 0}
             onClick={onToggleAllAssistants}
           >
             <span className={css.actionIcon} aria-hidden="true">

+ 8 - 6
packages/client/ui-trajectory/src/client/TrajectoryView.tsx

@@ -5,7 +5,7 @@ import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/clie
 import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
   AssistantMessageNode, ConversationContext,
-  SessionHistoryFace,
+  SessionHistoryFace, SnapshotStore,
 } from '@deepseek-ai/dsh-client-runtime/client'
 import {
   deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
@@ -29,8 +29,12 @@ const EMPTY_IDS: ReadonlySet<number> = new Set()
 
 /** Session-history paging needed by the event-complete trajectory view. */
 export interface TrajectoryViewInjected {
-  hooks: { history: SessionHistoryFace }
+  hooks: {
+    history: SessionHistoryFace
+    duration: SnapshotStore<boolean>
+  }
   loadAllHistory: (signal: AbortSignal) => Promise<void>
+  setActualDuration: (actualDuration: boolean) => void
 }
 
 interface UsageLike {
@@ -134,7 +138,7 @@ function searchMatches(
 }
 
 export function TrajectoryView({
-  useHistory, loadAllHistory, inspect, onInspectDone,
+  useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
 }: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
   const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
   const [collapsedAssistants, setCollapsedAssistants] =
@@ -143,7 +147,7 @@ export function TrajectoryView({
     branchId: number
     range: TrajectoryTimeRange
   } | null>(null)
-  const [actualDuration, setActualDuration] = useState(false)
+  const actualDuration = useDuration(value => value)
   const [actualTime, setActualTime] = useState(false)
   const [searchQuery, setSearchQuery] = useState('')
   const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
@@ -467,10 +471,8 @@ export function TrajectoryView({
           setActualTime(nextActualTime)
           setTimelineSelection(null)
         }}
-        collapsibleTurns={collapsibleTurnIds.length}
         allTurnsCollapsed={allTurnsCollapsed}
         onToggleAllTurns={toggleAllTurns}
-        collapsibleAssistants={collapsibleAssistantIds.length}
         allAssistantsCollapsed={allAssistantsCollapsed}
         onToggleAllAssistants={toggleAllAssistants}
         searchQuery={searchQuery}

+ 13 - 0
packages/client/ui-trajectory/src/client/duration-store.ts

@@ -0,0 +1,13 @@
+import {
+  createSnapshotStore, type SnapshotStore,
+} from '@deepseek-ai/dsh-client-runtime/client'
+
+/**
+ * Create the browser-wide trajectory duration preference source.
+ * @returns a persisted source shared by every session view in one plugin lifecycle.
+ */
+export function createTrajectoryDurationStore(): SnapshotStore<boolean> {
+  return createSnapshotStore(false, {
+    persist: { name: 'dsh.trajectory.duration' },
+  })
+}

+ 4 - 1
packages/client/ui-trajectory/src/client/index.ts

@@ -7,6 +7,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 // Type-only: the 'conversation.view' SlotMap row (declared by the slot's
 // owning package) must be in the program for the register calls to type.
 import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import { createTrajectoryDurationStore } from './duration-store.ts'
 import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
 
 /**
@@ -24,6 +25,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
  * @param ctx - client root context.
  */
 export function apply(ctx: Context): void {
+  const duration = createTrajectoryDurationStore()
   ctx.slots.register({
     name: 'conversation.view',
     id: 'trajectory',
@@ -32,8 +34,9 @@ export function apply(ctx: Context): void {
     inject: (sessionId: SessionId): TrajectoryViewInjected => {
       const history = ctx.sessionHistory.source(sessionId)
       return {
-        hooks: { history },
+        hooks: { history, duration },
         loadAllHistory: signal => history.loadAll(signal),
+        setActualDuration: (value) => { duration.set(value) },
       }
     },
   }, TrajectoryView)

+ 31 - 23
packages/client/ui-trajectory/src/client/timeline.ts

@@ -116,14 +116,9 @@ export function deriveTrajectoryTimeline(
 function deriveTimedTimeline(
   turns: readonly TrajectoryTurnModel[],
   actualDuration: boolean,
-  removeUserIdle: boolean,
+  compressIdle: boolean,
 ): TrajectoryTimelineModel | null {
-  const spans: TrajectoryTimelineSpan[] = []
-  const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
-  let removedUserIdle = 0
-  let previousTurnEnd: number | null = null
-
-  for (const turn of turns) {
+  const timedTurns = turns.flatMap((turn) => {
     const rawSpans = turn.groups.flatMap(group =>
       group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
         if (cell.requestOnly === true) return []
@@ -140,30 +135,43 @@ function deriveTimedTimeline(
           }]
       }),
     )
-    if (rawSpans.length === 0) continue
-
-    const turnStart = Math.min(...rawSpans.map(span => span.start))
-    const turnEnd = Math.max(...rawSpans.map(span => span.end))
-    if (removeUserIdle && previousTurnEnd !== null) {
-      removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
+    return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }]
+  })
+  const rawSpans = timedTurns.flatMap(turn => turn.rawSpans)
+  if (rawSpans.length === 0) return null
+
+  const removedIdleBySpan = new Map<TrajectoryTimelineSpan, number>()
+  let removedIdle = 0
+  let coveredUntil: number | null = null
+  for (const span of [...rawSpans].sort((left, right) =>
+    left.start - right.start || left.end - right.end)) {
+    if (compressIdle && coveredUntil !== null && span.start > coveredUntil) {
+      removedIdle += span.start - coveredUntil
     }
-    spans.push(...rawSpans.map(span => ({
-      ...span,
-      start: span.start - removedUserIdle,
-      end: (actualDuration ? span.end : span.start) - removedUserIdle,
-    })))
+    removedIdleBySpan.set(span, removedIdle)
+    coveredUntil = coveredUntil === null ? span.end : Math.max(coveredUntil, span.end)
+  }
+
+  const spans: TrajectoryTimelineSpan[] = []
+  const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
+  for (const turn of timedTurns) {
+    const projected = turn.rawSpans.map((span): TrajectoryTimelineSpan => {
+      const offset = removedIdleBySpan.get(span) ?? 0
+      return {
+        ...span,
+        start: span.start - offset,
+        end: (actualDuration ? span.end : span.start) - offset,
+      }
+    })
+    spans.push(...projected)
     if (turn.turn !== null) {
       turnBoundaries.push({
         turn: turn.turn,
-        time: turnStart - removedUserIdle,
+        time: Math.min(...projected.map(span => span.start)),
       })
     }
-    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)),

+ 3 - 0
packages/client/ui-trajectory/src/client/views.module.css

@@ -27,6 +27,9 @@
 }
 
 .ledger {
+  position: relative;
+  z-index: 0;
+  isolation: isolate;
   display: flex;
   flex: 1;
   min-height: 0;

+ 1 - 0
packages/client/ui-trajectory/tests/client-bundle.spec.ts

@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
     const modules = new Map<string, unknown>([
       ['react', await import('react')],
       ['react/jsx-runtime', await import('react/jsx-runtime')],
+      ['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
       ['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
     ])
     const surface = handoff!.factory((spec) => {

+ 3 - 1
packages/client/ui-trajectory/tests/table.spec.tsx

@@ -184,7 +184,9 @@ describe('TrajectoryTable', () => {
     expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
 
     fireEvent.mouseEnter(toolTag as HTMLElement)
-    expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
+    const tooltip = screen.getByRole('tooltip')
+    expect(tooltip.textContent).toBe('TOOL')
+    expect(tooltip.getAttribute('data-side')).toBe('right')
     fireEvent.mouseLeave(toolTag as HTMLElement)
     expect(screen.queryByRole('tooltip')).toBeNull()
   })

+ 162 - 7
packages/client/ui-trajectory/tests/views.spec.tsx

@@ -31,6 +31,7 @@ import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
 import {
   TrajectoryView, type TrajectoryViewInjected,
 } from '../src/client/TrajectoryView.tsx'
+import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
 import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
 
 const SID = 's1' as SessionId
@@ -96,6 +97,16 @@ function standaloneHistory(
   }
 }
 
+function standaloneDuration(): Pick<
+  ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
+> {
+  const duration = createSnapshotStore(false)
+  return {
+    useDuration: bindSnapshotSelector(duration),
+    setActualDuration: (value) => { duration.set(value) },
+  }
+}
+
 function fakeSession(nodes: ConversationSnapshot['nodes']) {
   const store = createSnapshotStore({
     nodes, pending: [], partial: null,
@@ -187,12 +198,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
       ? {}
       : injectEntry(SID)
     const injectedProps = 'hooks' in injected
-      ? {
-        loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
-        useHistory: bindSnapshotSelector(
-          (injected as TrajectoryViewInjected).hooks.history,
-        ),
-      }
+      ? (() => {
+        const trajectory = injected as TrajectoryViewInjected
+        return {
+          loadAllHistory: trajectory.loadAllHistory,
+          setActualDuration: trajectory.setActualDuration,
+          useHistory: bindSnapshotSelector(trajectory.hooks.history),
+          useDuration: bindSnapshotSelector(trajectory.hooks.duration),
+        }
+      })()
       : injected
     return (
       <View
@@ -241,6 +255,24 @@ describe('plugin registration', () => {
     await b.fiber.dispose()
     expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
   })
+
+  it('shares one browser-wide duration preference across session injections', async () => {
+    const b = await bench()
+    const entry = b.slots.entries('conversation.view')
+      .find(candidate => candidate.options.id === 'trajectory')
+    expect(entry).toBeDefined()
+    const injectEntry = entry!.inject as unknown as (
+      sessionId: SessionId,
+    ) => TrajectoryViewInjected
+    const first = injectEntry(SID)
+    const second = injectEntry('s2' as SessionId)
+
+    expect(second.hooks.duration).toBe(first.hooks.duration)
+    first.setActualDuration(true)
+    expect(second.hooks.duration.getSnapshot()).toBe(true)
+    expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
+    expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
+  })
 })
 
 describe('tab switching in ConversationRoot', () => {
@@ -460,6 +492,12 @@ describe('tab switching in ConversationRoot', () => {
     fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
     expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
     expect(screen.getByText('No timing data')).toBeTruthy()
+    expect(screen.getByRole<HTMLButtonElement>('button', {
+      name: 'Collapse turns',
+    }).disabled).toBe(false)
+    expect(screen.getByRole<HTMLButtonElement>('button', {
+      name: 'Collapse calls',
+    }).disabled).toBe(false)
     expect(screen.queryByRole('row')).toBeNull()
     expect(screen.queryByText(/turns ·/)).toBeNull()
   })
@@ -490,6 +528,28 @@ describe('timeline projection', () => {
     }],
   }] satisfies readonly TrajectoryTurnModel[]
 
+  it('cancels native scrolling across the timeline while zooming', () => {
+    render(
+      <TrajectoryTimeline
+        turns={longTurns}
+        mode="sequence"
+        range={null}
+        onRangeChange={vi.fn()}
+      />,
+    )
+    const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
+    vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
+      x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
+      toJSON: () => ({}),
+    })
+
+    expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
+    expect(fireEvent.wheel(screen.getByText('Input'), {
+      clientX: 20,
+      deltaY: -100,
+    })).toBe(false)
+  })
+
   it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
     const onRangeChange = vi.fn()
     const view = render(
@@ -542,7 +602,7 @@ describe('timeline projection', () => {
 
   it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
     const onRangeChange = vi.fn()
-    render(
+    const view = render(
       <TrajectoryTimeline
         turns={longTurns}
         mode="sequence"
@@ -560,13 +620,26 @@ describe('timeline projection', () => {
     for (let index = 0; index < 24; index++) {
       fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
     }
+    const draftSelection = view.container.querySelectorAll<HTMLElement>(
+      '[data-dragging="true"]',
+    )
+    expect(draftSelection).toHaveLength(2)
+    for (const overlay of draftSelection) {
+      expect(Number.parseFloat(
+        overlay.style.getPropertyValue('--trajectory-selection-left'),
+      )).toBeLessThan(0)
+    }
     fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
 
     const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
       | { start: number; end: number }
       | undefined
+    const fullRange = deriveTrajectoryTimeline(longTurns)
     expect(selectedRange).toBeDefined()
+    expect(fullRange).not.toBeNull()
     expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
+    expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
+    expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
   })
 
   it('uses equal-width operation slots and stable semantic lanes', () => {
@@ -657,6 +730,53 @@ describe('timeline projection', () => {
     })
   })
 
+  it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
+    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, 'duration')).toMatchObject({
+      start: 1_000,
+      end: 4_000,
+      spans: [
+        { index: 1, start: 1_000, end: 2_000 },
+        { index: 2, start: 2_000, end: 3_000 },
+        { index: 3, start: 3_000, end: 4_000 },
+      ],
+      turnBoundaries: [
+        { turn: 1, time: 1_000 },
+        { turn: 2, time: 3_000 },
+      ],
+    })
+    expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
+      start: 1_000,
+      end: 41_000,
+      spans: [
+        { index: 1, start: 1_000, end: 2_000 },
+        { index: 2, start: 4_000, end: 5_000 },
+        { index: 3, start: 40_000, end: 41_000 },
+      ],
+    })
+  })
+
   it('projects between-turn compaction without inventing a turn boundary', () => {
     const withStandaloneCompaction = [
       {
@@ -702,6 +822,7 @@ describe('timeline projection', () => {
       {
         ...standaloneProps([]),
         ...standaloneHistory(historySnapshot([])),
+        ...standaloneDuration(),
       },
     ))
     expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
@@ -710,6 +831,38 @@ describe('timeline projection', () => {
 })
 
 describe('TrajectoryView branches', () => {
+  it('persists the duration preference through the runtime snapshot-store seam', () => {
+    const firstDuration = createTrajectoryDurationStore()
+    const commonProps = {
+      ...standaloneProps(NODES),
+      ...standaloneHistory(historySnapshot(NODES)),
+    }
+    const first = render(
+      <TrajectoryView
+        {...commonProps}
+        useDuration={bindSnapshotSelector(firstDuration)}
+        setActualDuration={(value) => { firstDuration.set(value) }}
+      />,
+    )
+    const duration = screen.getByRole('button', { name: 'Use actual duration' })
+
+    expect(duration.getAttribute('aria-pressed')).toBe('false')
+    fireEvent.click(duration)
+    expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
+    first.unmount()
+
+    const restoredDuration = createTrajectoryDurationStore()
+    render(
+      <TrajectoryView
+        {...commonProps}
+        useDuration={bindSnapshotSelector(restoredDuration)}
+        setActualDuration={(value) => { restoredDuration.set(value) }}
+      />,
+    )
+    expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
+      .toBe('true')
+  })
+
   it('renders only the selected rewind branch while retaining session-global requests', () => {
     const retained = {
       kind: 'user',
@@ -765,6 +918,7 @@ describe('TrajectoryView branches', () => {
     const view = render(
       <TrajectoryView
         {...standaloneProps([])}
+        {...standaloneDuration()}
         useHistory={bindSnapshotSelector(store)}
         loadAllHistory={vi.fn(() => Promise.resolve())}
       />,
@@ -807,6 +961,7 @@ describe('TrajectoryView branches', () => {
     render(
       <TrajectoryView
         {...standaloneProps([])}
+        {...standaloneDuration()}
         useHistory={bindSnapshotSelector(store)}
         loadAllHistory={vi.fn(() => Promise.resolve())}
       />,