fix(web): use exact turn boundaries for run time

This commit is contained in:
imccyu
2026-08-04 15:55:07 +08:00
parent 2a828f09da
commit cc435a9c0c
26 changed files with 146 additions and 116 deletions

View File

@@ -331,6 +331,8 @@ export interface ConversationSnapshot {
sessionId: SessionId
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[]
/** Exact in-window `turn/start` time and optional matching `turn/end` time. */
turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
/** In-window completed turn number -> its `turn/end` event seq. */
turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null

View File

@@ -113,6 +113,11 @@ export class Session implements SessionFace {
private pendingCache: { rev: number; value: PendingInteraction[] } | null = null
private derivedRev = 0
private nodesCache: { projected: readonly ConversationNode[]; derivedRev: number; value: readonly ConversationNode[] } | null = null
/** Exact turn timing retained from the raw window so presentation never
* infers elapsed time from transcript content. */
private turnTimings = new Map<number, { startTime: number; endTime?: number }>()
private turnTimingsRev = 0
private turnTimingsCache: { rev: number; value: ConversationSnapshot['turnTimings'] } | null = null
/** Completed turn boundaries retained from the raw window so presentation
* actions never infer a safe fork point from transcript content alone. */
private turnEnds = new Map<number, number>()
@@ -799,6 +804,8 @@ export class Session implements SessionFace {
}
switch (event.type) {
case 'turn/start': {
this.turnTimings.set(event.data.turn, { startTime: event.time })
this.turnTimingsRev++
if (event.data.trigger.kind === 'retry') this.settleScheduledRetry('started')
return
}
@@ -830,6 +837,11 @@ export class Session implements SessionFace {
return
}
case 'turn/end': {
const timing = this.turnTimings.get(event.data.turn)
if (timing !== undefined) {
this.turnTimings.set(event.data.turn, { ...timing, endTime: event.time })
this.turnTimingsRev++
}
this.turnEnds.set(event.data.turn, event.seq)
this.turnEndsRev++
if (event.data.reason.kind === 'aborted' || event.data.reason.kind === 'disposed') {
@@ -922,6 +934,8 @@ export class Session implements SessionFace {
this.callsRev++
this.derivedNodes = []
this.derivedRev++
this.turnTimings = new Map()
this.turnTimingsRev++
this.turnEnds = new Map()
this.turnEndsRev++
this.codeDispatches = new Map()
@@ -955,6 +969,9 @@ export class Session implements SessionFace {
if (this.callsCache === null || this.callsCache.rev !== this.callsRev) {
this.callsCache = { rev: this.callsRev, value: [...this.openCalls.values()] }
}
if (this.turnTimingsCache === null || this.turnTimingsCache.rev !== this.turnTimingsRev) {
this.turnTimingsCache = { rev: this.turnTimingsRev, value: new Map(this.turnTimings) }
}
if (this.turnEndsCache === null || this.turnEndsCache.rev !== this.turnEndsRev) {
this.turnEndsCache = { rev: this.turnEndsRev, value: new Map(this.turnEnds) }
}
@@ -971,6 +988,7 @@ export class Session implements SessionFace {
return {
sessionId: this.sessionId,
nodes,
turnTimings: this.turnTimingsCache.value,
turnEnds: this.turnEndsCache.value,
partial,
runningCalls: this.callsCache.value,

View File

@@ -46,6 +46,11 @@ describe('open', () => {
expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant'])
expect(snapshot.turnTimings.get(3)).toEqual({
startTime: 1_700_000_000_010,
endTime: 1_700_000_000_015,
})
expect(snapshot.turnEnds.get(3)).toBe(15)
})
it('is idempotent: concurrent opens share one history call, reopening when open is a no-op', async () => {
@@ -253,11 +258,16 @@ describe('live event path', () => {
expect(snapshot.nodes.some(node => node.kind === 'turn-error')).toBe(false)
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' })
expect(snapshot.nodes.at(-1)).toMatchObject({ kind: 'assistant', blocks: [{ kind: 'text', text: '完整回复' }] })
expect([...snapshot.turnTimings]).toEqual([
[1, { startTime: 1_700_000_000_006, endTime: 1_700_000_000_013 }],
[2, { startTime: 1_700_000_000_014, endTime: 1_700_000_000_018 }],
])
const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
await replay.session.open()
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
expect(replay.session.getSnapshot().partial).toBeNull()
})
@@ -1254,6 +1264,7 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending)
expect(after.turnTimings).toBe(before.turnTimings)
expect(after.turnEnds).toBe(before.turnEnds)
// And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(11, 1, 'c1', 'ECHO'))

View File

@@ -46,6 +46,7 @@ export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot
return {
sessionId,
nodes: [],
turnTimings: new Map(),
turnEnds: new Map(),
partial: null,
runningCalls: [],

View File

@@ -30,11 +30,12 @@ import type {
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { assistantActionsSeqs, deriveChatFlow, lastInputTime, messageBranchSeqs, turnStartTimes, type ChatFlowItem } from './chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts'
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
import { GenericCommandCard } from './GenericCommandCard.tsx'
import { GenericToolCard } from './GenericToolCard.tsx'
import { MessageItem, PendingSteeringBubble } from './MessageItem.tsx'
import { formatRunDuration } from './message-chrome.ts'
import css from './ChatView.module.css'
const FOLLOW_THRESHOLD = 24
@@ -282,34 +283,37 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
})
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
function TurnStatus({ startTime }: {
/** The running turn's logged trigger time; null falls back to mount time
* (input node outside the window), which restarts the clock on reload. */
function TurnStatus({ startTime, t }: {
/** The running turn's logged `turn/start` time; null falls back to mount
* time when that boundary is outside the window. */
startTime: number | null
/** The owning view's locale seat. */
t: ChatViewSlotProps['t']
}) {
const [mountedAt] = useState(() => Date.now())
// Anchored to the logged trigger so a mid-turn reload keeps the real
// Anchored to turn/start so a mid-turn reload keeps the real
// elapsed time and the final footer's Ran-for label matches this clock.
const anchor = startTime ?? mountedAt
const [elapsedSeconds, setElapsedSeconds] = useState(() => Math.max(0, Math.floor((Date.now() - anchor) / 1000)))
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - anchor))
useEffect(() => {
const tick = (): void => {
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - anchor) / 1000)))
setElapsedMs(Math.max(0, Date.now() - anchor))
}
tick()
const id = setInterval(tick, 1000)
return () => { clearInterval(id) }
}, [anchor])
const minutes = Math.floor(elapsedSeconds / 60)
const seconds = elapsedSeconds % 60
const clock = minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`
// Short turns keep the plain label; the clock only appears once the turn
// has clearly been running for a while.
const showClock = elapsedSeconds >= 15
const showClock = elapsedMs >= 15_000
return (
<div className={css.turnStatus} role="status" aria-live="polite">
Deep diving...
{showClock && <span className={css.turnStatusClock}>{clock}</span>}
{showClock && (
<span className={css.turnStatusClock} aria-hidden>
{formatRunDuration(elapsedMs, t)}
</span>
)}
</div>
)
}
@@ -333,6 +337,7 @@ export function ChatView({
useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t,
}: ChatViewSlotProps) {
const nodes = useSession(s => s.nodes)
const turnTimings = useSession(s => s.turnTimings)
const turnEnds = useSession(s => s.turnEnds)
const inbox = useSession(s => s.queue)
// Workspace root off the session list row: path summaries display relative to it.
@@ -356,8 +361,7 @@ export function ChatView({
// text (before tools) omits `time` so AssistantMarkdown stays chrome-free.
const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes])
const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds])
const turnStarts = useMemo(() => turnStartTimes(nodes), [nodes])
const runningTurnStart = useMemo(() => lastInputTime(nodes), [nodes])
const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings])
const listRef = useRef<HTMLDivElement | null>(null)
const columnRef = useRef<HTMLDivElement | null>(null)
@@ -594,14 +598,16 @@ export function ChatView({
}
const node: ConversationNode = item.node
if (node.kind === 'assistant') {
const turnStart = actionSeqs.has(node.seq) ? turnStarts.get(node.turn) : undefined
const timing = actionSeqs.has(node.seq) ? turnTimings.get(node.turn) : undefined
return (
<AssistantMarkdown
blocks={node.blocks}
streaming={false}
interrupted={node.interrupted}
time={actionSeqs.has(node.seq) ? node.time : undefined}
runMs={turnStart === undefined ? undefined : Math.max(0, node.time - turnStart)}
runMs={timing?.endTime === undefined
? undefined
: Math.max(0, timing.endTime - timing.startTime)}
seq={node.seq}
onFork={forkAt}
forkUnavailable={!branchSeqs.has(node.seq)}
@@ -679,7 +685,7 @@ export function ChatView({
double-render the same wait. */}
{/* Turn-level loading signal: rides the whole running turn (first-token
wait, tool execution, streaming) so it never flickers per step. */}
{running && <TurnStatus startTime={runningTurnStart} />}
{running && <TurnStatus startTime={runningTurnStart} t={t} />}
{pendingSteering.map(item => (
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
))}

View File

@@ -50,7 +50,7 @@ export function MessageIconActions({
{runMs !== undefined && (
<>
<span className={css.runTimeDot} aria-hidden>·</span>
{t('message.ranFor', { duration: formatRunDuration(runMs) })}
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
</>
)}
</span>

View File

@@ -9,7 +9,7 @@
* flow share their gates.
*/
import type {
AssistantBlock, ConversationNode, ToolResultNode,
AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */
@@ -48,40 +48,18 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
}
/**
* Approximate turn start times: turn number -> the time of the nearest
* user/steering node preceding the turn's first assistant node. Same
* derivation family as the trajectory fold (adjacent logged timestamps, no
* dedicated turn/start state); a turn whose triggering input is outside the
* loaded window is simply absent.
* @param nodes - snapshot nodes in event order.
* @returns Unix epoch ms per in-window turn with a visible trigger.
* Exact start time of the latest in-window turn without a matching end time.
* @param turnTimings - In-window turn timings in event order.
* @returns Unix epoch ms, or null when the running turn started outside the window.
*/
export function turnStartTimes(nodes: readonly ConversationNode[]): ReadonlyMap<number, number> {
const starts = new Map<number, number>()
let lastInputTime: number | null = null
for (const node of nodes) {
if (node.kind === 'user' || node.kind === 'steering') {
lastInputTime = node.time
} else if (node.kind === 'assistant' && lastInputTime !== null && !starts.has(node.turn)) {
starts.set(node.turn, lastInputTime)
}
export function runningTurnStartTime(
turnTimings: ConversationSnapshot['turnTimings'],
): number | null {
let latest: number | null = null
for (const timing of turnTimings.values()) {
if (timing.endTime === undefined) latest = timing.startTime
}
return starts
}
/**
* Time of the last user/steering node in the window: the running turn's
* trigger, anchoring the live TurnStatus clock to the same logged instant the
* finalized footer's run time is measured from.
* @param nodes - snapshot nodes in event order.
* @returns Unix epoch ms, or null when no input node is in-window.
*/
export function lastInputTime(nodes: readonly ConversationNode[]): number | null {
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i]
if (node?.kind === 'user' || node?.kind === 'steering') return node.time
}
return null
return latest
}
/**

View File

@@ -6,6 +6,9 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
/** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'>
/** The elapsed-duration share of the conversation dictionary. */
export type RunDurationTranslate = Translate<'duration.seconds' | 'duration.minutes'>
/**
* Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard.
@@ -72,16 +75,18 @@ export function msUntilNextLocalMidnight(ms: number): number {
}
/**
* Compact elapsed-time label for the turn run-time chrome, matching the
* running TurnStatus clock format: `15s` under a minute, `2m 05s` from there.
* Localized elapsed-time label shared by running and settled turn chrome.
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
* @param t - Translate seat supplying the duration templates.
* @returns Display string in whole seconds.
*/
export function formatRunDuration(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000))
export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
const total = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(total / 60)
const seconds = total % 60
return minutes > 0 ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s`
return minutes > 0
? t('duration.minutes', { minutes, seconds: String(seconds).padStart(2, '0') })
: t('duration.seconds', { seconds })
}
/**

View File

@@ -69,6 +69,8 @@ export const zh = {
'message.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败',
'message.ranFor': '用时 {duration}',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…',
'command.failed': '命令失败',
'command.done': '已完成',
@@ -178,6 +180,8 @@ export const en = {
'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed',
'message.ranFor': 'Ran for {duration}',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…',
'command.failed': 'Command failed',
'command.done': 'Completed',

View File

@@ -67,7 +67,7 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [],
): ConversationSnapshot {
return {
sessionId: SID, nodes, turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
sessionId: SID, nodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls, codeDispatches,
pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -32,7 +32,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, lastInputTime, messageBranchSeqs, turnStartTimes } from '../src/client/chat/chat-flow.ts'
import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts'
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
afterEach(() => {
@@ -37,7 +37,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}
@@ -242,33 +242,23 @@ describe('chat-flow derivation', () => {
expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
})
it('turnStartTimes anchors each turn to the nearest preceding input node', () => {
const starts = turnStartTimes([
user(1, 'hi'),
assistant(2, 'mid', 1),
assistant(5, 'done', 1),
{
kind: 'steering', messageId: 'st' as never,
seq: 6, time: 6_000, turn: 1,
content: [{ type: 'text', text: 'also' }], source: null,
},
assistant(7, 'second turn', 2),
])
expect([...starts]).toEqual([[1, 1_000], [2, 6_000]])
// No input in-window before the turn's first assistant: the turn is absent.
expect(turnStartTimes([assistant(2, 'orphan', 1)]).size).toBe(0)
it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
expect(runningTurnStartTime(new Map([
[1, { startTime: 1_000, endTime: 5_000 }],
[2, { startTime: 6_000 }],
]))).toBe(6_000)
expect(runningTurnStartTime(new Map([
[1, { startTime: 1_000, endTime: 5_000 }],
[2, { startTime: 6_000, endTime: 9_000 }],
]))).toBeNull()
})
it('formatRunDuration matches the running clock format', () => {
expect(formatRunDuration(0)).toBe('0s')
expect(formatRunDuration(-500)).toBe('0s')
expect(formatRunDuration(15_400)).toBe('15s')
expect(formatRunDuration(125_000)).toBe('2m 05s')
})
it('lastInputTime returns the trailing input node time', () => {
expect(lastInputTime([user(1, 'hi'), assistant(2, 'mid', 1)])).toBe(1_000)
expect(lastInputTime([assistant(2, 'orphan', 1)])).toBeNull()
it('formatRunDuration localizes units and floors partial seconds', () => {
const t = makeTranslate(zh, commonZh)
expect(formatRunDuration(0, t)).toBe('0秒')
expect(formatRunDuration(-500, t)).toBe('0秒')
expect(formatRunDuration(15_999, t)).toBe('15秒')
expect(formatRunDuration(125_000, t)).toBe('2分05秒')
})
it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
@@ -539,18 +529,21 @@ describe('ChatView', () => {
nodes: [
user(1, 'hi'), // time 1_000
assistant(2, 'mid-turn text'),
assistant(16, 'final answer'), // time 16_000 → 15s
assistant(16, 'final answer'),
toolResult(18, 'trailing'),
],
turnEnds: new Map([[1, 16]]),
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
turnEnds: new Map([[1, 20]]),
})
const view = render(<h.ChatView {...h.props} />)
// Only the turn-tail footer carries the label; mid-turn chrome stays bare.
expect(view.getAllByText(/用时 15s/)).toHaveLength(1)
// The exact turn/end includes trailing tool activity after the final text.
expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
})
it('user and assistant message containers scope the hover-revealed time chrome', () => {
const h = makeHarness({
nodes: [user(1, 'hi'), assistant(2, 'answer')],
turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
turnEnds: new Map([[1, 2]]),
})
const view = render(<h.ChatView {...h.props} />)
@@ -727,12 +720,24 @@ describe('ChatView', () => {
expect(view.getByRole('status').textContent).toBe('Deep diving...')
})
it('the running TurnStatus clock anchors to the logged trigger time, surviving reload', () => {
const trigger: UserMessageNode = { ...user(1, 'go'), time: Date.now() - 125_000 }
const h = makeHarness({ nodes: [trigger], running: true })
it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
const startTime = Date.now() - 125_000
const trigger: UserMessageNode = { ...user(1, 'go'), time: startTime + 1 }
const h = makeHarness({
nodes: [trigger], turnTimings: new Map([[1, { startTime }]]), running: true,
})
const view = render(<h.ChatView {...h.props} />)
// Freshly mounted (as after a reload) yet already past the 15s gate.
expect(view.getByRole('status').textContent).toMatch(/^Deep diving\.\.\.2m 0\ds$/)
const status = view.getByRole('status')
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
expect(status.querySelector('[aria-hidden="true"]')).not.toBeNull()
act(() => {
h.set({ nodes: [trigger, {
kind: 'steering', messageId: 'st' as never, seq: 2, time: Date.now(), turn: 1,
content: [{ type: 'text', text: 'also' }], source: null,
}] })
})
expect(status.textContent).toMatch(/^Deep diving\.\.\.2分0\d秒$/)
})
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {

View File

@@ -335,7 +335,7 @@ describe('DetailsPanel diff Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -23,7 +23,7 @@ const SID = 's1' as SessionId
function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -26,7 +26,7 @@ const SID = 's1' as SessionId
/** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -112,7 +112,7 @@ async function scopedBench(register?: (slash: SlashService) => void) {
actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({
sessionId, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -32,7 +32,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu
function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null,
}

View File

@@ -283,7 +283,7 @@ describe('DetailsPanel Output section (read)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -397,7 +397,7 @@ describe('DetailsPanel Output section (search)', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -68,7 +68,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState =>
function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -477,7 +477,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -231,7 +231,7 @@ describe('DetailsPanel web Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return {
sessionId: SID, nodes: [], turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
sessionId: SID, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], codeDispatches: new Map(),
pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,