fix(trajectory): persist idle-compressed duration mode

This commit is contained in:
_Kerman
2026-07-31 11:12:43 +08:00
parent 668ba2bfe2
commit 4624c476de
4 changed files with 124 additions and 27 deletions

View File

@@ -8,7 +8,7 @@ export interface TrajectoryToolbarProps {
actualDuration: boolean actualDuration: boolean
/** Select recorded-duration or equal-width blocks. */ /** Select recorded-duration or equal-width blocks. */
onActualDurationChange: (actualDuration: boolean) => void onActualDurationChange: (actualDuration: boolean) => void
/** Whether recorded timing retains idle gaps between user turns. */ /** Whether recorded timing retains idle gaps between operations. */
actualTime: boolean actualTime: boolean
/** Select complete wall-clock timing or idle-compressed timing. */ /** Select complete wall-clock timing or idle-compressed timing. */
onActualTimeChange: (actualTime: boolean) => void onActualTimeChange: (actualTime: boolean) => void

View File

@@ -26,6 +26,29 @@ import {
import css from './views.module.css' import css from './views.module.css'
const EMPTY_IDS: ReadonlySet<number> = new Set() const EMPTY_IDS: ReadonlySet<number> = 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. */ /** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected { export interface TrajectoryViewInjected {
@@ -143,7 +166,7 @@ export function TrajectoryView({
branchId: number branchId: number
range: TrajectoryTimeRange range: TrajectoryTimeRange
} | null>(null) } | null>(null)
const [actualDuration, setActualDuration] = useState(false) const [actualDuration, setActualDuration] = useState(restoreActualDuration)
const [actualTime, setActualTime] = useState(false) const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
@@ -457,6 +480,7 @@ export function TrajectoryView({
<TrajectoryToolbar <TrajectoryToolbar
actualDuration={actualDuration} actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => { onActualDurationChange={(nextActualDuration) => {
persistActualDuration(nextActualDuration)
setActualDuration(nextActualDuration) setActualDuration(nextActualDuration)
setTimelineSelection(null) setTimelineSelection(null)
}} }}

View File

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

View File

@@ -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', () => { it('empty inputs produce no model and the standalone view reports its empty form', () => {
expect(deriveTrajectoryTimeline([])).toBeNull() expect(deriveTrajectoryTimeline([])).toBeNull()
render(createElement( render(createElement(
@@ -575,6 +622,24 @@ describe('timeline projection', () => {
}) })
describe('TrajectoryView branches', () => { describe('TrajectoryView branches', () => {
it('persists the duration preference across trajectory view mounts', () => {
const props = {
...standaloneProps(NODES),
...standaloneHistory(historySnapshot(NODES)),
}
const first = render(<TrajectoryView {...props} />)
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(<TrajectoryView {...props} />)
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', () => { it('renders only the selected rewind branch while retaining session-global requests', () => {
const retained = { const retained = {
kind: 'user', kind: 'user',