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
/** 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

View File

@@ -26,6 +26,29 @@ import {
import css from './views.module.css'
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. */
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<number | null>(null)
@@ -457,6 +480,7 @@ export function TrajectoryView({
<TrajectoryToolbar
actualDuration={actualDuration}
onActualDurationChange={(nextActualDuration) => {
persistActualDuration(nextActualDuration)
setActualDuration(nextActualDuration)
setTimelineSelection(null)
}}

View File

@@ -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<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,
})))
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)),

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', () => {
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(<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', () => {
const retained = {
kind: 'user',