mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
fix(trajectory): persist idle-compressed duration mode
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user