feat(web): show turn run time with hover-revealed time chrome
Derive turn wall time from adjacent logged timestamps (no new session
events): the actions-owning assistant footer gains a localized
"Ran for {duration}" label, and the running TurnStatus label gains a
live elapsed clock anchored to the same logged trigger so a mid-turn
reload keeps the real elapsed time. Message time chrome is now
hover-revealed on hover-capable devices, keeping icons visible and
layout stable.
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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:
|
||||
# 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.zh.md: a9c78997e79530047b9d5edba2cc3a1018b90f15
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Web turn run time and hover-revealed time chrome
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-03-web-turn-run-time.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The Web chat shows when a message arrived but not how long the agent worked on it. Long turns give no live progress signal beyond the static activity label, and after the turn settles the wall time is not recoverable from the UI. Meanwhile the always-visible clock row adds visual noise to every message.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
**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.
|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,27 @@
|
||||
# Agent Note: Web 轮次运行时长与悬停显示的时间附属元素
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-03-web-turn-run-time.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Web 聊天界面会显示消息的到达时间,却不显示 agent(智能体)处理这条消息花了多久。长轮次除静态活动标签外没有任何实时进度信号,轮次结束后也无法从 UI 中还原实际耗时。与此同时,始终可见的时钟行给每条消息都增加了视觉噪音。
|
||||
|
||||
## 决策
|
||||
|
||||
轮次实际耗时(wall time)由相邻的已记录时间戳推导,不新增任何会话事件:`turnStartTimes` 把每个轮次映射到其前方最近的用户或 steering(中途引导)节点的时间,承载操作图标的 assistant 页脚在时钟旁把 `time - turnStart` 渲染为 `Ran for {duration}` 标签。运行中的 `TurnStatus` 标签新增一个实时已用时长时钟,锚定在 `lastInputTime` 上(即页脚计时所依据的同一已记录时刻),因此轮次进行中重新加载页面仍能保留真实的已用时长,最终标签也与实时时钟一致。该时钟在 15 秒后才出现,短轮次因此仍显示不带时钟的普通标签。
|
||||
|
||||
时钟与运行时长这类时间附属元素(time chrome)在悬停时才显示:消息容器通过 `data-time-hover-root` 属性显式启用该行为,`MessageIconActions.module.css` 在容器处于 `:hover`/`:focus-within` 时以淡入方式显示时间标签。该规则限定在 `@media (hover: hover)` 之内,触屏设备因此保持标签始终可见;显隐通过 opacity(而非 display)实现,布局保持稳定。复制与分支图标始终可见。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**专门的轮次开始会话事件。** 精确,但会仅为 UI 展示就新增一种模型不可见的事件类型;基于相邻时间戳的推导与轨迹表既有的推导方式属于同一族,且无需改动日志。若轮次的触发节点位于已加载窗口之外,则直接省略该标签。
|
||||
|
||||
**将实时时钟锚定到组件挂载时刻。** 更简单,但轮次进行中重新加载会让时钟从零重新计时,并与最终的页脚标签不一致。挂载时刻仅作为窗口内没有输入节点时的回退。
|
||||
|
||||
**将整个操作行隐藏至悬停时才显示。** 在评审中被否决:复制与分支是值得让用户发现的操作入口,而整行级别的显隐切换有布局偏移的风险。只有被动的时间文本由悬停控制显隐。
|
||||
|
||||
## 后果
|
||||
|
||||
轮次时长在运行中和结束后都可见,且不需要新的会话状态,两处读数在机制上必然一致。触发输入落在已加载窗口之外的轮次没有运行时长标签。未交互时,时间附属元素不再与消息内容争夺注意力;标签始终保留在 DOM 中,因此 ARIA 树快照不受影响。
|
||||
@@ -27,6 +27,9 @@ export interface AssistantMarkdownProps {
|
||||
/** Unix epoch ms for the IconActions clock; omitted while streaming or when
|
||||
* the parent withholds chrome (mid-turn content assistants). */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms for the IconActions run-time label; omitted when the
|
||||
* turn's triggering input is outside the loaded window. */
|
||||
runMs?: number | undefined
|
||||
/** Event sequence used as the fork boundary; omitted while streaming. */
|
||||
seq?: number | undefined
|
||||
/** Fork the session through this finalized message's completed turn when eligible. */
|
||||
@@ -79,7 +82,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass
|
||||
}
|
||||
|
||||
export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
blocks, streaming, interrupted, time, seq, onFork, forkUnavailable, t,
|
||||
blocks, streaming, interrupted, time, runMs, seq, onFork, forkUnavailable, t,
|
||||
}: AssistantMarkdownProps) {
|
||||
// Stable per locale revision (t identity changes on switch): a fresh object
|
||||
// per render would rebuild MarkdownText's component table every chunk.
|
||||
@@ -95,7 +98,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
// Footer only under settled content text; Think-only / streaming omit it.
|
||||
const showActions = !streaming && time !== undefined && hasContentText(blocks)
|
||||
return (
|
||||
<div className={css.root} data-streaming={streaming || undefined}>
|
||||
<div className={css.root} data-streaming={streaming || undefined} data-time-hover-root>
|
||||
<div className={css.body}>
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.kind) {
|
||||
@@ -121,6 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({
|
||||
<MessageIconActions
|
||||
text={copyText(blocks)}
|
||||
time={time}
|
||||
runMs={runMs}
|
||||
clock="end"
|
||||
onBranch={onFork === undefined || seq === undefined ? undefined : () => { onFork(seq) }}
|
||||
branchUnavailable={forkUnavailable}
|
||||
|
||||
@@ -93,6 +93,15 @@
|
||||
animation: dsh-turn-status-shimmer 1.8s linear infinite;
|
||||
}
|
||||
|
||||
.turnStatusClock {
|
||||
margin-left: 8px;
|
||||
font: var(--dsw-font-xs-13);
|
||||
font-weight: 400;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
-webkit-text-fill-color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
@keyframes dsh-turn-status-shimmer {
|
||||
to {
|
||||
background-position: 0 0;
|
||||
|
||||
@@ -30,7 +30,7 @@ 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, messageBranchSeqs, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, lastInputTime, messageBranchSeqs, turnStartTimes, type ChatFlowItem } from './chat-flow.ts'
|
||||
import { AssistantMarkdown } from './AssistantMarkdown.tsx'
|
||||
import { GenericCommandCard } from './GenericCommandCard.tsx'
|
||||
import { GenericToolCard } from './GenericToolCard.tsx'
|
||||
@@ -205,10 +205,34 @@ const CommandRow = memo(function CommandRow({ renderSlot, node, t }: {
|
||||
})
|
||||
|
||||
/** Turn-level model activity label retained across first-token, tool, and streaming phases. */
|
||||
function TurnStatus() {
|
||||
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. */
|
||||
startTime: number | null
|
||||
}) {
|
||||
const [mountedAt] = useState(() => Date.now())
|
||||
// Anchored to the logged trigger 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)))
|
||||
useEffect(() => {
|
||||
const tick = (): void => {
|
||||
setElapsedSeconds(Math.max(0, Math.floor((Date.now() - anchor) / 1000)))
|
||||
}
|
||||
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
|
||||
return (
|
||||
<div className={css.turnStatus} role="status" aria-live="polite">
|
||||
Deep diving...
|
||||
{showClock && <span className={css.turnStatusClock}>{clock}</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -259,6 +283,8 @@ 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 listRef = useRef<HTMLDivElement | null>(null)
|
||||
const atBottomRef = useRef(true)
|
||||
@@ -406,6 +432,7 @@ export function ChatView({
|
||||
}
|
||||
const node: ConversationNode = item.node
|
||||
if (node.kind === 'assistant') {
|
||||
const turnStart = actionSeqs.has(node.seq) ? turnStarts.get(node.turn) : undefined
|
||||
return (
|
||||
<AssistantMarkdown
|
||||
key={item.key}
|
||||
@@ -413,6 +440,7 @@ export function ChatView({
|
||||
streaming={false}
|
||||
interrupted={node.interrupted}
|
||||
time={actionSeqs.has(node.seq) ? node.time : undefined}
|
||||
runMs={turnStart === undefined ? undefined : Math.max(0, node.time - turnStart)}
|
||||
seq={node.seq}
|
||||
onFork={forkAt}
|
||||
forkUnavailable={!branchSeqs.has(node.seq)}
|
||||
@@ -481,7 +509,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 />}
|
||||
{running && <TurnStatus startTime={runningTurnStart} />}
|
||||
{pendingSteering.map(item => (
|
||||
<PendingSteeringBubble key={item.id} content={item.content} t={t} />
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Shared message IconActions row (user + assistant). Parent modules own
|
||||
layout offsets via the composed className. Always visible when mounted. */
|
||||
layout offsets via the composed className. Icons stay visible when mounted;
|
||||
the time label is hover-revealed inside a data-time-hover-root scope. */
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
@@ -25,6 +26,26 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Separator between the clock and the run-time label (time · Ran for 15s). */
|
||||
.runTimeDot {
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
/* Message containers opt in with data-time-hover-root: the time label fades
|
||||
in on message hover (or keyboard focus within). Opacity keeps the layout
|
||||
stable, and devices without hover keep the label always visible. */
|
||||
@media (hover: hover) {
|
||||
[data-time-hover-root] :is(.timeStart, .timeEnd) {
|
||||
opacity: 0;
|
||||
transition: opacity 80ms ease;
|
||||
}
|
||||
|
||||
[data-time-hover-root]:hover :is(.timeStart, .timeEnd),
|
||||
[data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
IconBranchOutline16, IconCopyOutline16, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatViewSlotProps } from '../contract/slots.ts'
|
||||
import { formatMessageClock, writeClipboard } from './message-chrome.ts'
|
||||
import { formatMessageClock, formatRunDuration, writeClipboard } from './message-chrome.ts'
|
||||
import { useCalendarDay } from './use-calendar-day.ts'
|
||||
import css from './MessageIconActions.module.css'
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface MessageIconActionsProps {
|
||||
text: string
|
||||
/** Unix epoch ms for the clock label; omitted for transient messages. */
|
||||
time?: number | undefined
|
||||
/** Turn wall time in ms, appended to the clock as `· Ran for 15s`; omitted when the turn's start is unknown. */
|
||||
runMs?: number | undefined
|
||||
/** Clock before icons (user) or after (assistant). */
|
||||
clock: 'start' | 'end'
|
||||
/** Fork the session at this message; omission hides the branch action. */
|
||||
@@ -35,7 +37,7 @@ export interface MessageIconActionsProps {
|
||||
* @returns The actions row element.
|
||||
*/
|
||||
export function MessageIconActions({
|
||||
text, time, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
text, time, runMs, clock, onBranch, branchUnavailable = false, showBranch = true, className, t,
|
||||
}: MessageIconActionsProps) {
|
||||
const day = useCalendarDay()
|
||||
const reasonId = useId()
|
||||
@@ -45,6 +47,12 @@ export function MessageIconActions({
|
||||
const clockEl = time === undefined ? null : (
|
||||
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
|
||||
{formatMessageClock(time, t, day)}
|
||||
{runMs !== undefined && (
|
||||
<>
|
||||
<span className={css.runTimeDot} aria-hidden>·</span>
|
||||
{t('message.ranFor', { duration: formatRunDuration(runMs) })}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
return (
|
||||
|
||||
@@ -183,7 +183,7 @@ function UserStyleBubble({
|
||||
const { text, rest } = contentText(content)
|
||||
const truncated = (total: number): string => t('json.truncated', { total })
|
||||
return (
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined}>
|
||||
<div className={css.userRow} data-pending-steering={pending || undefined} data-time-hover-root>
|
||||
<div className={css.bubble}>
|
||||
{projectUserText(text)}
|
||||
{rest.map((block, i) => <JsonBlock key={i} label={t('message.extraBlock')} payload={block} truncatedLabel={truncated} />)}
|
||||
|
||||
@@ -47,6 +47,43 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
|
||||
return new Set(lastByTurn.values())
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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)
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Seq set of message rows that may fork: the last transcript node of a
|
||||
* completed turn, when that node owns message chrome. A later tool, reasoning,
|
||||
|
||||
@@ -71,6 +71,19 @@ export function msUntilNextLocalMidnight(ms: number): number {
|
||||
return Math.max(next.getTime() - ms, 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact elapsed-time label for the turn run-time chrome, matching the
|
||||
* running TurnStatus clock format: `15s` under a minute, `2m 05s` from there.
|
||||
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
|
||||
* @returns Display string in whole seconds.
|
||||
*/
|
||||
export function formatRunDuration(ms: number): string {
|
||||
const total = Math.max(0, Math.round(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`
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact local timestamp for message IconActions. Same calendar day →
|
||||
* `HH:mm`; earlier this year → the `clock.md` date template + clock; other
|
||||
|
||||
@@ -68,6 +68,7 @@ export const zh = {
|
||||
'message.retry.delay': '重试延迟:',
|
||||
'message.retry.failure': '失败原因:',
|
||||
'message.turnError': '本轮运行失败',
|
||||
'message.ranFor': '用时 {duration}',
|
||||
'command.running': '执行中…',
|
||||
'command.failed': '命令失败',
|
||||
'command.done': '已完成',
|
||||
@@ -176,6 +177,7 @@ export const en = {
|
||||
'message.retry.delay': 'Retry delay: ',
|
||||
'message.retry.failure': 'Failure reason: ',
|
||||
'message.turnError': 'This turn failed',
|
||||
'message.ranFor': 'Ran for {duration}',
|
||||
'command.running': 'Running…',
|
||||
'command.failed': 'Command failed',
|
||||
'command.done': 'Completed',
|
||||
|
||||
@@ -20,7 +20,8 @@ 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, messageBranchSeqs } from '../src/client/chat/chat-flow.ts'
|
||||
import { assistantActionsSeqs, deriveChatFlow, flowKeys, lastInputTime, messageBranchSeqs, turnStartTimes } from '../src/client/chat/chat-flow.ts'
|
||||
import { formatRunDuration } from '../src/client/chat/message-chrome.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
// Keyless create() persists under the bare declared key; clear between cases
|
||||
@@ -212,6 +213,35 @@ 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('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('messageBranchSeqs keeps only message rows at completed transcript tails', () => {
|
||||
const interruptedThink: AssistantMessageNode = {
|
||||
kind: 'assistant', seq: 4.1, time: 4_100, turn: 1, step: 2,
|
||||
@@ -447,6 +477,39 @@ describe('ChatView', () => {
|
||||
expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual(['true', null, 'true', null])
|
||||
})
|
||||
|
||||
it('the actions-owning assistant footer shows the turn run time', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [
|
||||
user(1, 'hi'), // time 1_000
|
||||
assistant(2, 'mid-turn text'),
|
||||
assistant(16, 'final answer'), // time 16_000 → 15s
|
||||
],
|
||||
turnEnds: new Map([[1, 16]]),
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
it('user and assistant message containers scope the hover-revealed time chrome', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'hi'), assistant(2, 'answer')],
|
||||
turnEnds: new Map([[1, 2]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
// One scope per message row; the CSS reveal keys off this attribute.
|
||||
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('the run-time label is withheld when the turn start is outside the window', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [assistant(16, 'tail without trigger')],
|
||||
turnEnds: new Map([[1, 16]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByText(/用时/)).toBeNull()
|
||||
})
|
||||
|
||||
it('enables fork only on the finalized assistant at the completed transcript tail', () => {
|
||||
const h = makeHarness({
|
||||
nodes: [user(1, 'question'), assistant(2, 'answer')],
|
||||
@@ -607,6 +670,14 @@ 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 })
|
||||
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$/)
|
||||
})
|
||||
|
||||
it('dispatches each tool row through the keyed slot with the tool name as entryKey', () => {
|
||||
const h = makeHarness({ nodes: [toolResult(3, 'a')] })
|
||||
const calls: { key: string; entryKey?: string }[] = []
|
||||
|
||||
Reference in New Issue
Block a user