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

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-turn-run-time.md # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-03-web-turn-run-time.md
2026-08-03-web-turn-run-time.md: 456cc75279bdeeadb67f0d8a1fe7f281e3e87d30 2026-08-03-web-turn-run-time.md: b6e79b34f45ebe46d9ce752b6333cdfce6fc4dd6
2026-08-03-web-turn-run-time.zh.md: a9c78997e79530047b9d5edba2cc3a1018b90f15 2026-08-03-web-turn-run-time.zh.md: 73d9be4d2119278f8a78c6858bae353a4ef5d62f

View File

@@ -10,18 +10,18 @@ The Web chat shows when a message arrived but not how long the agent worked on i
## Decision ## Decision
Turn wall time derives from adjacent logged timestamps, with no new session events: `turnStartTimes` maps each turn to the nearest preceding user/steering node's time, and the actions-owning assistant footer renders `time - turnStart` as a `Ran for {duration}` label next to the clock. The running `TurnStatus` label gains a live elapsed clock anchored to `lastInputTime` — the same logged instant the footer measures from — so a mid-turn reload keeps the real elapsed time and the final label matches the live clock. The clock appears only after 15 seconds so short turns keep the plain label. Turn wall time uses the existing logged `turn/start` and `turn/end` timestamps, with no new session events. The client Session folds each in-window pair into `turnTimings`; the actions-owning assistant footer renders `endTime - startTime` as a localized `Ran for {duration}` label after the turn ends. The running `TurnStatus` clock uses the latest timing without an end, so reload preserves elapsed time, steering does not reset it, and a retry starts from its own logged boundary. Both readings use the same localized formatter and whole-second floor. The clock appears only after 15 seconds and is hidden from the live region so screen readers announce the activity status without replaying every tick.
Time chrome (clock and run time) is hover-revealed: message containers opt in with a `data-time-hover-root` attribute, and `MessageIconActions.module.css` fades the time label in on container `:hover`/`:focus-within`. The rule is scoped to `@media (hover: hover)`, so touch devices keep the always-visible label; opacity (not display) keeps the layout stable. Copy/branch icons stay always visible. Time chrome (clock and run time) is hover-revealed: message containers opt in with a `data-time-hover-root` attribute, and `MessageIconActions.module.css` fades the time label in on container `:hover`/`:focus-within`. The rule is scoped to `@media (hover: hover)`, so touch devices keep the always-visible label; opacity (not display) keeps the layout stable. Copy/branch icons stay always visible.
## Alternatives considered ## Alternatives considered
**A dedicated turn-start session event.** Precise, but adds a model-invisible event type solely for UI display; the adjacent-timestamp fold matches the trajectory table's existing derivation family and needs no log change. A turn whose trigger is outside the loaded window simply omits the label. **Deriving timing from message nodes.** The nearest user or steering timestamp is available in the rendered transcript, but it mismeasures retry turns and lets mid-turn steering reset the live clock. Existing turn boundary events provide the authoritative timestamps without changing the log format.
**Anchoring the live clock to component mount.** Simpler, but a mid-turn reload would restart the clock at zero and disagree with the eventual footer label. Mount time remains only the fallback when no input node is in-window. **Anchoring the live clock to component mount.** Simpler, but a mid-turn reload would restart the clock at zero and disagree with the eventual footer label. Mount time remains only the fallback when `turn/start` is outside the loaded window.
**Hiding the whole actions row until hover.** Rejected in review: copy/branch are affordances worth discovering, and row-level show/hide risks layout shift. Only the passive time text is hover-gated. **Hiding the whole actions row until hover.** Copy and branch are affordances worth discovering, and row-level show/hide risks layout shift. Only the passive time text is hover-gated.
## Consequences ## Consequences
Turn duration is visible live and after settlement without new session state, and the two readings agree by construction. The run-time label is absent for turns whose triggering input fell outside the loaded window. Time chrome no longer competes with message content at rest; ARIA-tree snapshots are unaffected because the label stays in the DOM. Turn duration is visible live and after settlement without new session events, and both readings share exact log boundaries and formatting. The settled duration includes activity after the last assistant text up to `turn/end`; the label is absent when `turn/start` is outside the loaded window. Time chrome no longer competes with message content at rest, and the ticking clock remains visual rather than repeatedly announced.

View File

@@ -10,18 +10,18 @@ Web 聊天界面会显示消息的到达时间,却不显示 agent(智能体
## 决策 ## 决策
轮次实际耗时(wall time)由相邻的已记录时间戳推导,不新增任何会话事件:`turnStartTimes` 把每个轮次映射到其前方最近的用户或 steering(中途引导)节点的时间,承载操作图标的 assistant 页脚在时钟旁把 `time - turnStart` 渲染为 `Ran for {duration}` 标签。运行中的 `TurnStatus` 标签新增一个实时已用时长时钟,锚定在 `lastInputTime` 上(即页脚计时所依据的同一已记录时刻),因此轮次进行中重新加载页面仍能保留真实的已用时长,最终标签也与实时时钟一致。该时钟在 15 秒后才出现,短轮次因此仍显示不带时钟的普通标签。 轮次实际耗时(wall time)采用日志中已有的 `turn/start` 和 `turn/end` 时间戳,不新增任何会话事件。客户端 Session 将加载窗口内的每对边界归并到 `turnTimings` 中;轮次结束后,承载操作图标的 assistant 页脚把 `endTime - startTime` 渲染为本地化的 `Ran for {duration}` 标签。运行中的 `TurnStatus` 时钟采用最新一条没有结束时间的计时记录,因此重新加载会保留已用时长,steering(中途引导)不会重置计时,重试也从自身的日志边界开始。两处读数共用同一个本地化格式化器,并向下取整到整秒。该时钟在 15 秒后才出现,并从实时区域中隐藏,因此屏幕阅读器会播报活动状态而不会重复播报每次时钟跳动。
时钟与运行时长这类时间附属元素(time chrome)在悬停时才显示:消息容器通过 `data-time-hover-root` 属性显式启用该行为,`MessageIconActions.module.css` 在容器处于 `:hover`/`:focus-within` 时以淡入方式显示时间标签。该规则限定在 `@media (hover: hover)` 之内,触屏设备因此保持标签始终可见;显隐通过 opacity(而非 display)实现,布局保持稳定。复制与分支图标始终可见。 时钟与运行时长这类时间附属元素(time chrome)在悬停时才显示:消息容器通过 `data-time-hover-root` 属性显式启用该行为,`MessageIconActions.module.css` 在容器处于 `:hover`/`:focus-within` 时以淡入方式显示时间标签。该规则限定在 `@media (hover: hover)` 之内,触屏设备因此保持标签始终可见;显隐通过 opacity(而非 display)实现,布局保持稳定。复制与分支图标始终可见。
## 考虑过的替代方案 ## 考虑过的替代方案
**专门的轮次开始会话事件。** 精确,但会仅为 UI 展示就新增一种模型不可见的事件类型;基于相邻时间戳的推导与轨迹表既有的推导方式属于同一族,且无需改动日志。若轮次的触发节点位于已加载窗口之外,则直接省略该标签。 **从消息节点推导计时。** 渲染后的 transcript(文本记录)中可以取得最近的用户或 steering 时间戳,但这会错误计算重试轮次,并让轮次中途的 steering 重置实时时钟。已有的轮次边界事件无需改变日志格式即可提供权威时间戳。
**将实时时钟锚定到组件挂载时刻。** 更简单,但轮次进行中重新加载会让时钟从零重新计时,并与最终的页脚标签不一致。挂载时刻仅作为窗口内没有输入节点时的回退。 **将实时时钟锚定到组件挂载时刻。** 更简单,但轮次进行中重新加载会让时钟从零重新计时,并与最终的页脚标签不一致。仅当 `turn/start` 位于已加载窗口之外时,才回退到挂载时刻。
**将整个操作行隐藏至悬停时才显示。** 在评审中被否决:复制与分支是值得让用户发现的操作入口,而整行级别的显隐切换有布局偏移的风险。只有被动的时间文本由悬停控制显隐。 **将整个操作行隐藏至悬停时才显示。** 复制与分支是值得让用户发现的操作入口,而整行级别的显隐切换有布局偏移的风险。只有被动的时间文本由悬停控制显隐。
## 后果 ## 后果
轮次时长在运行中和结束后都可见,且不需要新的会话状态,两处读数在机制上必然一致。触发输入落在已加载窗口之外的轮次没有运行时长标签。未交互时,时间附属元素不再与消息内容争夺注意力;标签始终保留在 DOM 中,因此 ARIA 树快照不受影响。 轮次时长在运行中和结束后都可见,且不需要新的会话事件;两处读数共用精确的日志边界和格式化方式。结束后的时长包括最后一条 assistant 文本之后、直至 `turn/end` 的活动;若 `turn/start` 位于已加载窗口之外,则不显示标签。未交互时,时间附属元素不再与消息内容争夺注意力,持续跳动的时钟也只保留视觉呈现,不会被重复播报。

View File

@@ -331,6 +331,8 @@ export interface ConversationSnapshot {
sessionId: SessionId sessionId: SessionId
/** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */ /** Human transcript plus retry notices and interrupted-turn terminal nodes in event order. */
nodes: readonly ConversationNode[] 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. */ /** In-window completed turn number -> its `turn/end` event seq. */
turnEnds: ReadonlyMap<number, number> turnEnds: ReadonlyMap<number, number>
partial: PartialAssistant | null partial: PartialAssistant | null

View File

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

View File

@@ -46,6 +46,11 @@ describe('open', () => {
expect(snapshot.openState).toBe('open') expect(snapshot.openState).toBe('open')
expect(snapshot.hasMore).toBe(true) expect(snapshot.hasMore).toBe(true)
expect(snapshot.nodes.map(n => n.kind)).toEqual(['user', 'assistant']) 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 () => { 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.some(node => node.kind === 'turn-error')).toBe(false)
expect(snapshot.nodes.at(-2)).toMatchObject({ kind: 'model-retry', retryState: 'started' }) 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.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() const replay = makeSession()
replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn]) replay.api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...retryTurn])
await replay.session.open() await replay.session.open()
expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes) expect(replay.session.getSnapshot().nodes).toEqual(snapshot.nodes)
expect(replay.session.getSnapshot().turnTimings).toEqual(snapshot.turnTimings)
expect(replay.session.getSnapshot().partial).toBeNull() expect(replay.session.getSnapshot().partial).toBeNull()
}) })
@@ -1254,6 +1264,7 @@ describe('reference stability (the memo contract)', () => {
expect(after).not.toBe(before) expect(after).not.toBe(before)
expect(after.runningCalls).toBe(before.runningCalls) expect(after.runningCalls).toBe(before.runningCalls)
expect(after.pending).toBe(before.pending) expect(after.pending).toBe(before.pending)
expect(after.turnTimings).toBe(before.turnTimings)
expect(after.turnEnds).toBe(before.turnEnds) expect(after.turnEnds).toBe(before.turnEnds)
// And a mutation on the tracked domain swaps that array. // And a mutation on the tracked domain swaps that array.
feed(ev.toolResult(11, 1, 'c1', 'ECHO')) feed(ev.toolResult(11, 1, 'c1', 'ECHO'))

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,7 @@
* flow share their gates. * flow share their gates.
*/ */
import type { import type {
AssistantBlock, ConversationNode, ToolResultNode, AssistantBlock, ConversationNode, ConversationSnapshot, ToolResultNode,
} from '@deepseek-ai/dsh-client-runtime/client' } from '@deepseek-ai/dsh-client-runtime/client'
/** One renderable flow item; key is the React key and the parent's identity unit. */ /** 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 * Exact start time of the latest in-window turn without a matching end time.
* user/steering node preceding the turn's first assistant node. Same * @param turnTimings - In-window turn timings in event order.
* derivation family as the trajectory fold (adjacent logged timestamps, no * @returns Unix epoch ms, or null when the running turn started outside the window.
* 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.
*/ */
export function turnStartTimes(nodes: readonly ConversationNode[]): ReadonlyMap<number, number> { export function runningTurnStartTime(
const starts = new Map<number, number>() turnTimings: ConversationSnapshot['turnTimings'],
let lastInputTime: number | null = null ): number | null {
for (const node of nodes) { let latest: number | null = null
if (node.kind === 'user' || node.kind === 'steering') { for (const timing of turnTimings.values()) {
lastInputTime = node.time if (timing.endTime === undefined) latest = timing.startTime
} else if (node.kind === 'assistant' && lastInputTime !== null && !starts.has(node.turn)) {
starts.set(node.turn, lastInputTime)
}
} }
return starts return latest
}
/**
* 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
} }
/** /**

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. */ /** The date-template share of the conversation dictionary the clock consumes. */
export type ClockTranslate = Translate<'clock.md' | 'clock.ymd'> 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). * Best-effort clipboard write; rejections stay swallowed (no success chrome).
* @param text - Plain text to place on the clipboard. * @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 * Localized elapsed-time label shared by running and settled turn chrome.
* running TurnStatus clock format: `15s` under a minute, `2m 05s` from there.
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero). * @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
* @param t - Translate seat supplying the duration templates.
* @returns Display string in whole seconds. * @returns Display string in whole seconds.
*/ */
export function formatRunDuration(ms: number): string { export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
const total = Math.max(0, Math.round(ms / 1000)) const total = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(total / 60) const minutes = Math.floor(total / 60)
const seconds = 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.retry.failure': '失败原因:',
'message.turnError': '本轮运行失败', 'message.turnError': '本轮运行失败',
'message.ranFor': '用时 {duration}', 'message.ranFor': '用时 {duration}',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…', 'command.running': '执行中…',
'command.failed': '命令失败', 'command.failed': '命令失败',
'command.done': '已完成', 'command.done': '已完成',
@@ -178,6 +180,8 @@ export const en = {
'message.retry.failure': 'Failure reason: ', 'message.retry.failure': 'Failure reason: ',
'message.turnError': 'This turn failed', 'message.turnError': 'This turn failed',
'message.ranFor': 'Ran for {duration}', 'message.ranFor': 'Ran for {duration}',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…', 'command.running': 'Running…',
'command.failed': 'Command failed', 'command.failed': 'Command failed',
'command.done': 'Completed', 'command.done': 'Completed',

View File

@@ -67,7 +67,7 @@ function snapshotWith(
runningCalls: RunningToolCall[] = [], runningCalls: RunningToolCall[] = [],
): ConversationSnapshot { ): ConversationSnapshot {
return { 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, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false,
openState: 'open', openError: null, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: 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 { function snapshotBase(): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: 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 { createChatStore } from '../src/client/stores.ts'
import { ChatView } from '../src/client/chat/ChatView.tsx' import { ChatView } from '../src/client/chat/ChatView.tsx'
import { zh } from '../src/client/locales.ts' 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' import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
afterEach(() => { afterEach(() => {
@@ -37,7 +37,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: 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]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7])
}) })
it('turnStartTimes anchors each turn to the nearest preceding input node', () => { it('runningTurnStartTime selects the latest turn/start without a turn/end', () => {
const starts = turnStartTimes([ expect(runningTurnStartTime(new Map([
user(1, 'hi'), [1, { startTime: 1_000, endTime: 5_000 }],
assistant(2, 'mid', 1), [2, { startTime: 6_000 }],
assistant(5, 'done', 1), ]))).toBe(6_000)
{ expect(runningTurnStartTime(new Map([
kind: 'steering', messageId: 'st' as never, [1, { startTime: 1_000, endTime: 5_000 }],
seq: 6, time: 6_000, turn: 1, [2, { startTime: 6_000, endTime: 9_000 }],
content: [{ type: 'text', text: 'also' }], source: null, ]))).toBeNull()
},
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('formatRunDuration matches the running clock format', () => { it('formatRunDuration localizes units and floors partial seconds', () => {
expect(formatRunDuration(0)).toBe('0s') const t = makeTranslate(zh, commonZh)
expect(formatRunDuration(-500)).toBe('0s') expect(formatRunDuration(0, t)).toBe('0秒')
expect(formatRunDuration(15_400)).toBe('15s') expect(formatRunDuration(-500, t)).toBe('0秒')
expect(formatRunDuration(125_000)).toBe('2m 05s') expect(formatRunDuration(15_999, t)).toBe('15秒')
}) expect(formatRunDuration(125_000, t)).toBe('2分05秒')
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('messageBranchSeqs keeps only message rows at completed transcript tails', () => { it('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
@@ -539,18 +529,21 @@ describe('ChatView', () => {
nodes: [ nodes: [
user(1, 'hi'), // time 1_000 user(1, 'hi'), // time 1_000
assistant(2, 'mid-turn text'), 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} />) const view = render(<h.ChatView {...h.props} />)
// Only the turn-tail footer carries the label; mid-turn chrome stays bare. // The exact turn/end includes trailing tool activity after the final text.
expect(view.getAllByText(/用时 15s/)).toHaveLength(1) expect(view.getAllByText(/用时 19秒/)).toHaveLength(1)
}) })
it('user and assistant message containers scope the hover-revealed time chrome', () => { it('user and assistant message containers scope the hover-revealed time chrome', () => {
const h = makeHarness({ const h = makeHarness({
nodes: [user(1, 'hi'), assistant(2, 'answer')], nodes: [user(1, 'hi'), assistant(2, 'answer')],
turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
turnEnds: new Map([[1, 2]]), turnEnds: new Map([[1, 2]]),
}) })
const view = render(<h.ChatView {...h.props} />) const view = render(<h.ChatView {...h.props} />)
@@ -727,12 +720,24 @@ describe('ChatView', () => {
expect(view.getByRole('status').textContent).toBe('Deep diving...') expect(view.getByRole('status').textContent).toBe('Deep diving...')
}) })
it('the running TurnStatus clock anchors to the logged trigger time, surviving reload', () => { it('the running clock uses turn/start, ignores steering, and stays out of the live region', () => {
const trigger: UserMessageNode = { ...user(1, 'go'), time: Date.now() - 125_000 } const startTime = Date.now() - 125_000
const h = makeHarness({ nodes: [trigger], running: true }) 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} />) const view = render(<h.ChatView {...h.props} />)
// Freshly mounted (as after a reload) yet already past the 15s gate. // 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', () => { 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 { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,

View File

@@ -24,7 +24,7 @@ const SID = 's1' as SessionId
function snapshotBase(): ConversationSnapshot { function snapshotBase(): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: 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 { function snapshotOf(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, 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). */ /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */
function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) {
const session = createSnapshotStore<ConversationSnapshot>({ 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', pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active',
removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false,
loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, 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) actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined)
const wiring = shell const wiring = shell
const sessionStore = createSnapshotStore<ConversationSnapshot>({ 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, 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 { function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot {
return { 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, pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null,
hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: 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 { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, 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 { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, 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 { function conversationSnapshot(overrides: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, promptError: null, blank: false, subagent: null, lastAgentError: null,

View File

@@ -477,7 +477,7 @@ describe('DetailsPanel Output section', () => {
function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, 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 { function snapshot(over: Partial<ConversationSnapshot> = {}): ConversationSnapshot {
return { 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, pending: [], queue: [], running: false, composerPhase: 'active', removed: false,
openState: 'open', openError: null, hasMore: false, loadingOlder: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false,
promptError: null, blank: false, subagent: null, lastAgentError: null, ...over, promptError: null, blank: false, subagent: null, lastAgentError: null, ...over,