diff --git a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx index 09d213c36e..50bb85ad70 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryToolbar.tsx @@ -8,7 +8,7 @@ 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 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index e09b03e51c..420c4ac5e9 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -26,6 +26,29 @@ import { import css from './views.module.css' const EMPTY_IDS: ReadonlySet = new Set() +const DURATION_STORAGE_KEY = 'dsh.trajectory.duration' + +/** Restore the browser-wide duration preference; absent or unreadable storage defaults off. */ +function restoreActualDuration(): boolean { + if (typeof localStorage === 'undefined') return false + try { + return localStorage.getItem(DURATION_STORAGE_KEY) === 'true' + } catch { + // Storage access can throw in privacy mode; the default remains usable. + return false + } +} + +/** Persist the browser-wide duration preference without making storage availability fatal. */ +function persistActualDuration(actualDuration: boolean): void { + if (typeof localStorage === 'undefined') return + try { + localStorage.setItem(DURATION_STORAGE_KEY, String(actualDuration)) + } catch { + // Storage access can throw in privacy mode or at quota; this mount still + // keeps the selected preference in React state. + } +} /** Session-history paging needed by the event-complete trajectory view. */ export interface TrajectoryViewInjected { @@ -143,7 +166,7 @@ export function TrajectoryView({ branchId: number range: TrajectoryTimeRange } | null>(null) - const [actualDuration, setActualDuration] = useState(false) + const [actualDuration, setActualDuration] = useState(restoreActualDuration) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') const [selectedTimelineIndex, setSelectedTimelineIndex] = useState(null) @@ -457,6 +480,7 @@ export function TrajectoryView({ { + persistActualDuration(nextActualDuration) setActualDuration(nextActualDuration) setTimelineSelection(null) }} diff --git a/packages/client/ui-trajectory/src/client/timeline.ts b/packages/client/ui-trajectory/src/client/timeline.ts index 88682b04bb..b91ec51849 100644 --- a/packages/client/ui-trajectory/src/client/timeline.ts +++ b/packages/client/ui-trajectory/src/client/timeline.ts @@ -114,14 +114,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 [] @@ -138,28 +133,41 @@ function deriveTimedTimeline( }] }), ) - if (rawSpans.length === 0) continue + return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }] + }) + const rawSpans = timedTurns.flatMap(turn => turn.rawSpans) + if (rawSpans.length === 0) return null - 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) + const removedIdleBySpan = new Map() + 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, - }))) - turnBoundaries.push({ - turn: turn.turn, - time: turnStart - removedUserIdle, - }) - previousTurnEnd = previousTurnEnd === null - ? turnEnd - : Math.max(previousTurnEnd, turnEnd) + 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) + turnBoundaries.push({ + turn: turn.turn, + time: Math.min(...projected.map(span => span.start)), + }) } - if (spans.length === 0) return null return { start: Math.min(...spans.map(span => span.start)), end: Math.max(...spans.map(span => span.end)), diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index ebb5f28bb8..e2d6560f4e 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -560,6 +560,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('empty inputs produce no model and the standalone view reports its empty form', () => { expect(deriveTrajectoryTimeline([])).toBeNull() render(createElement( @@ -575,6 +622,24 @@ describe('timeline projection', () => { }) describe('TrajectoryView branches', () => { + it('persists the duration preference across trajectory view mounts', () => { + const props = { + ...standaloneProps(NODES), + ...standaloneHistory(historySnapshot(NODES)), + } + const first = render() + 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() + + render() + 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',