fix(trajectory): persist idle-compressed duration mode
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user