feat(ui-chat): split turn stats into usage and time pills with dialogs

The turn tail's exposed meta line collapses into two icon pills — Usage
(database glyph, turn total) and Ran-for (clock glyph, wall time) — each
click-opening a details dialog; the calendar clock trails as plain text.
Cache hit, TPS, and TTFT move dialog-only, and narrow viewports collapse
the pills to bare icons. The TEMPORARY flat variant keeps the whole-line
trigger for the A/B test.
This commit is contained in:
Yif 2026-08-28 01:34:18 +08:00
parent f15078532f
commit 9effa0c6b3
60 changed files with 834 additions and 218 deletions

View file

@ -44,7 +44,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -44,7 +44,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -23,7 +23,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -35,7 +35,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -39,7 +39,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -48,7 +48,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m2 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m2 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r2
@ -60,7 +63,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m3 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m3 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r3
@ -72,7 +78,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m4 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m4 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r4
@ -84,7 +93,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m5 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m5 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r5
@ -96,7 +108,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m6 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m6 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r6
@ -108,7 +123,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m7 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m7 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r7
@ -120,7 +138,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m8 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m8 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r8
@ -132,7 +153,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m9 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m9 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r9
@ -144,7 +168,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m10 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m10 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r10
@ -156,7 +183,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m11 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m11 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r11
@ -168,7 +198,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m12 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m12 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r12
@ -180,7 +213,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m13 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m13 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r13
@ -192,7 +228,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m14 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m14 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r14
@ -204,7 +243,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m15 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m15 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r15
@ -216,7 +258,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m16 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m16 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r16
@ -228,7 +273,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m17 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m17 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r17
@ -240,7 +288,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m18 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m18 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r18
@ -252,7 +303,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m19 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m19 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r19
@ -264,7 +318,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m20 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m20 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r20
@ -276,7 +333,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m21 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m21 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r21
@ -288,7 +348,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m22 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m22 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r22
@ -300,7 +363,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m23 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m23 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r23
@ -312,7 +378,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m24 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m24 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r24
@ -324,7 +393,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m25 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m25 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r25
@ -336,7 +408,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m26 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m26 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r26
@ -348,7 +423,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m27 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m27 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r27
@ -360,7 +438,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} m28 7/25 {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}} m28 7/25 {{clock}}
- button "Copy":
- img
- paragraph: r28
@ -372,7 +453,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- button "Back to bottom":
- img
- textbox "Message or run a task... / commands, @ files or sessions"

View file

@ -46,7 +46,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -34,7 +34,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -173,10 +173,12 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ })
await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1)
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
// The pill carries the icon, the turn total, and the cache-hit rate;
// timing facts stay in the plain meta line beside it.
expect(await trigger.textContent()).toBe('Usage 15.8K tok · Cache hit 49.7%')
expect(await page.getByText(/^.+ · Ran for .+ · \d+ tok\/s · TTFT \S+$/).count()).toBe(1)
// The usage pill carries the icon and the turn total; the time pill beside
// it carries the run time, and both keep their details dialog-only.
expect(await trigger.textContent()).toBe('Usage 15.8K tok')
const timeTrigger = page.getByRole('button', { name: /^Ran for \S+$/ })
expect(await timeTrigger.count()).toBe(1)
expect(await page.locator('[data-turn-tail]').getByText(/tok\/s|TTFT/).count()).toBe(0)
expect(await page.getByRole('dialog').count()).toBe(0)
await trigger.click()
@ -189,6 +191,16 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
expect(await dialog.getByText('7,808 tok', { exact: true }).count()).toBe(1)
expect(await dialog.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1)
await page.keyboard.press('Escape')
expect(await page.getByRole('dialog').count()).toBe(0)
await timeTrigger.click()
const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
expect(await timeDialog.count()).toBe(1)
expect(await timeDialog.getByText(/tok\/s/).count()).toBe(1)
expect(await timeDialog.getByText('Average time to first token (TTFT)', { exact: true }).count()).toBe(1)
await page.keyboard.press('Escape')
await trigger.click()
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)

View file

@ -20,36 +20,16 @@
white-space: nowrap;
}
/* The assistant tail's clock reads the secondary tier: its row carries the
detailed-usage cluster, so the whole meta line sits one step under body. */
/* The assistant tail's clock sits at the row end after the stat pills; the
row's 8px flex gap spaces it, and it reads the secondary tier so it matches
the pill labels beside it. */
.timeEnd {
padding-left: 12px;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Separator between the clock and the run-time label (time · Ran for 15s). */
.runTimeDot {
margin: 0 5px;
}
/* Separator between the usage pill and the plain meta text; the row's 8px
flex gap spaces it, so the meta line drops its own 12px inset. The negative
margin rebates most of the pill's internal 8px padding so the label-to-dot
distance reads like the dot's other side, while keeping 2px clear of the
pill's hover background. */
.actionsDot {
margin-left: -6px;
font-size: var(--dsh-content-font-size-secondary, 13px);
color: var(--dsw-alias-label-tertiary);
}
.actionsDot + .timeEnd {
padding-left: 0;
}
/* Row containers (user rows and turn tails) opt in with
data-actions-reveal='hover': every row but the latest of its kind reveals
the entire actions row (icons and text) on hover or focus-within, while the

View file

@ -1,14 +1,12 @@
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
import { Fragment, useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
import {
IconBranchOutline16, IconCheckOutline16, IconCopyOutline16, Tooltip, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import {
formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond,
} from './message-chrome.ts'
import { formatMessageClock } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@ -17,12 +15,6 @@ 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
/** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
tokensPerSecond?: number | undefined
/** Turn first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
ttftMs?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
@ -51,7 +43,7 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
text, time, runMs, tokensPerSecond, ttftMs, clock, onBranch, branchUnavailable = false, className,
text, time, clock, onBranch, branchUnavailable = false, className,
extraActions, usageAction, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
@ -82,30 +74,9 @@ export function MessageIconActions({
}, 1000)
})
}, [copied, text])
// The dots are decorative and stay hidden, but their margins separate the
// readings only on screen: without the flanking spaces a reader hears one
// run-on string ("8/26 22:08Ran for 13s") instead of separate facts.
const meta: string[] = []
if (time !== undefined) meta.push(formatMessageClock(time, t, day))
if (runMs !== undefined) meta.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) }))
if (tokensPerSecond !== undefined) {
meta.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) }))
}
if (ttftMs !== undefined) meta.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) }))
const clockEl = meta.length === 0 ? null : (
const clockEl = time === undefined ? null : (
<span className={clock === 'start' ? css.timeStart : css.timeEnd}>
{meta.map((segment, index) => (
<Fragment key={segment}>
{index > 0 && (
<>
{' '}
<span className={css.runTimeDot} aria-hidden>·</span>
{' '}
</>
)}
{segment}
</Fragment>
))}
{formatMessageClock(time, t, day)}
</span>
)
return (
@ -137,9 +108,6 @@ export function MessageIconActions({
<span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
)}
{usageAction}
{usageAction != null && clock === 'end' && clockEl !== null && (
<span className={css.actionsDot} aria-hidden>·</span>
)}
{clock === 'end' ? clockEl : null}
</div>
)

View file

@ -2,7 +2,7 @@ import { memo } from 'react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
import { TurnTimePanel, TurnUsagePanel } from './TurnUsagePanel.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
@ -28,6 +28,14 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
// TEMPORARY usage-variant debug switch for user testing: `?usage-variant=flat`
// or `#usage-variant=flat` (the hash form survives the login token's 303
// redirect to a clean `/`) hands the whole meta line to TurnUsagePanel as the
// dialog trigger; the default keeps the icon pill beside plain meta text.
// Delete after the test.
const flatVariant = data.tokenUsage !== undefined
&& [window.location.search, window.location.hash.replace(/^#/, '')]
.some(query => new URLSearchParams(query).get('usage-variant') === 'flat')
// Interruption-frozen partials carry no messageId, so they address no
// durable message and contribute no per-message actions.
const messageId = closing.finalNode.messageId
@ -43,18 +51,37 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
{tail}
<MessageIconActions
text={assistantText(closing.blocks)}
time={closing.time}
runMs={runMs}
tokensPerSecond={data.tokensPerSecond}
ttftMs={data.ttftMs}
{...flatVariant ? {} : { time: closing.time }}
clock="end"
onBranch={() => { forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
usageAction={data.tokenUsage === undefined ? null : (
<TurnUsagePanel usage={data.tokenUsage} t={t} />
)}
usageAction={flatVariant
? data.tokenUsage !== undefined && (
<TurnUsagePanel
usage={data.tokenUsage}
variant="flat"
time={closing.time}
runMs={runMs}
tokensPerSecond={data.tokensPerSecond}
ttftMs={data.ttftMs}
t={t}
/>
)
: (
<>
{data.tokenUsage !== undefined && <TurnUsagePanel usage={data.tokenUsage} t={t} />}
{runMs !== undefined && (
<TurnTimePanel
runMs={runMs}
tokensPerSecond={data.tokensPerSecond}
ttftMs={data.ttftMs}
t={t}
/>
)}
</>
)}
t={t}
/>
</div>

View file

@ -7,6 +7,13 @@
min-width: 0;
}
/* Adjacent stat pills (usage then time) rebate part of their combined internal
padding so the pair reads as one cluster inside the row's 8px flex gap,
while keeping 2px clear between their hover backgrounds. */
.root + .root {
margin-left: -6px;
}
/* Same pill rules as the sibling `.action` icon buttons (28px hit height,
15px glyph, tertiary → secondary on hover with the interactive hover
background), widened to carry the turn-total label. */
@ -36,6 +43,36 @@
text-overflow: ellipsis;
}
/* TEMPORARY (usage-variant debug): the flat whole-line trigger reads like the
plain meta text, brightening on hover to hint clickability. The margin
widens the row's 8px flex gap off the branch icon beside it. */
.flatTrigger {
min-width: 0;
margin-left: 6px;
padding: 0;
border: none;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
font-variant-numeric: tabular-nums;
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.flatTrigger:hover,
.flatTrigger[aria-expanded='true'] {
color: var(--dsw-alias-label-secondary);
}
/* TEMPORARY (usage-variant debug): flat-line separators breathe wider than the
plain meta line's 5px runTimeDot rhythm. */
.flatDot {
margin: 0 8px;
}
.trigger svg {
width: calc(15px + var(--dsh-content-font-delta, 0px));
height: calc(15px + var(--dsh-content-font-delta, 0px));
@ -48,6 +85,13 @@
color: var(--dsw-alias-label-secondary);
}
/* Narrow viewport: the pills collapse to bare icons; the dialogs keep the words. */
@media (max-width: 480px) {
.trigger .label {
display: none;
}
}
/* Portal surface: fixed in the viewport, left/top supplied inline from the
anchored-position clamp so the panel keeps its 12px viewport margin instead
of hanging off the trigger and clipping at the window edge. Portaled panels
@ -73,13 +117,46 @@
cursor: default;
}
/* Dialog heading, same size and weight as the total row it concludes with. */
/* Dialog heading row: the section name left, the section's headline value
(the usage total) right, both at the primary weight. */
.title {
margin-bottom: 12px;
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
color: var(--dsw-alias-label-primary);
font-weight: 500;
}
/* Second-section heading (Turn time), spaced well off the usage rows above. */
.timeTitle {
margin-top: 20px;
}
/* Rule under each section heading, above its rows. */
.titleRule {
margin-bottom: 10px;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.titleValue {
font-variant-numeric: tabular-nums;
}
/* Section-heading glyph seated left of the section name. */
.titleLabel {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.titleLabel svg {
width: 14px;
height: 14px;
flex: none;
}
.details {
display: grid;
grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
@ -108,17 +185,3 @@
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
/* Full-bleed rule above the total row: the grid's column gap would break a
per-cell border-top, so the line is its own row spanning both columns. */
.separator {
grid-column: 1 / -1;
margin: 4px 0;
border-top: 1px solid var(--dsw-alias-border-l2);
}
.totalLabel,
.details .totalValue {
color: var(--dsw-alias-label-primary);
font-weight: 500;
}

View file

@ -1,17 +1,48 @@
// Icon-row Turn-usage action: a data-icon pill labelled with the turn total
// (and the cache-hit rate when known) that click-opens the per-Turn details
// dialog. Sits right of the branch action in the tail's IconActions row.
// Icon-row Turn-stat actions: a database pill labelled with the turn total
// click-opens the per-Turn usage dialog, and a clock pill labelled with the
// turn wall time click-opens the Turn-time dialog. Both sit right of the
// branch action in the tail's IconActions row, ahead of the plain clock text.
// TEMPORARY: the `flat` variant (usage-variant debug switch) renders the
// whole meta line as one trigger whose dialog stacks both sections; one of
// the two variants will be deleted after user testing.
import { useEffect, useRef, useState, type CSSProperties } from 'react'
import { Fragment, useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
import { createPortal } from 'react-dom'
import { IconDataOutline16, useAnchoredPosition } from '@deepseek-ai/dsh-client-ui-primitives'
import {
IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import {
formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond,
} from './message-chrome.ts'
import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './TurnUsagePanel.module.css'
export interface TurnUsagePanelProps {
usage: TurnTokenUsage
/** Trigger form: the default icon pill or the temporary flat whole-line text. */
variant?: 'pill' | 'flat'
/** Unix epoch ms for the flat trigger's clock segment. */
time?: number | undefined
/** Turn wall time in ms; the flat trigger and its dialog's Turn-time section show it. */
runMs?: number | undefined
/** Turn decode throughput; the flat trigger and its dialog's Turn-time section show it. */
tokensPerSecond?: number | undefined
/** Turn first-step TTFT in ms; the flat trigger and its dialog's Turn-time section show it. */
ttftMs?: number | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
export interface TurnTimePanelProps {
/** Turn wall time in ms, the pill's label. */
runMs: number
/** Turn decode throughput, a dialog row when known. */
tokensPerSecond?: number | undefined
/** Turn first-step TTFT in ms, a dialog row when known. */
ttftMs?: number | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@ -36,12 +67,16 @@ const PANEL_GAP = 8
*/
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Turn-usage IconActions pill with a click-open Turn-details dialog.
* @param props - Turn usage buckets and locale seat.
* @returns The icon-and-total trigger and, while open, its portaled dialog anchored above the trigger.
*/
export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
interface StatDialogSeat {
open: boolean
setOpen: (open: boolean) => void
rootRef: MutableRefObject<HTMLSpanElement | null>
panelRef: MutableRefObject<HTMLDivElement | null>
pos: CSSProperties | null
}
/** One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close. */
function useStatDialog(): StatDialogSeat {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
@ -77,27 +112,88 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
}
}, [open])
return { open, setOpen, rootRef, panelRef, pos }
}
/** Turn-time dialog rows: run time plus throughput and TTFT when known. */
function TimeDetails({ runMs, tokensPerSecond, ttftMs, t }: {
runMs: number | undefined
tokensPerSecond: number | undefined
ttftMs: number | undefined
t: ChatViewSlotProps['t']
}) {
return (
<dl className={css.details} data-turn-time-details>
{runMs !== undefined && (
<>
<dt>{t('message.turnTime.duration')}</dt>
<dd>{formatRunDuration(runMs, t)}</dd>
</>
)}
{tokensPerSecond !== undefined && (
<>
<dt>{t('message.turnTime.speed')}</dt>
<dd>{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}</dd>
</>
)}
{ttftMs !== undefined && (
<>
<dt>{t('message.turnTime.ttft')}</dt>
<dd>{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}</dd>
</>
)}
</dl>
)
}
/**
* Turn-usage IconActions pill (or the temporary flat meta-line trigger) with
* a click-open Turn-usage details dialog.
* @param props - Turn usage buckets, trigger form, and locale seat.
* @returns The trigger and, while open, its portaled dialog anchored above the trigger.
*/
export function TurnUsagePanel({ usage, variant = 'pill', time, runMs, tokensPerSecond, ttftMs, t }: TurnUsagePanelProps) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
const day = useCalendarDay()
const cacheHit = usage.cacheReadTokens === undefined
? null
: formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
const total = formatCompactCount(usage.totalTokens, t)
const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
const flatSegments: string[] = []
if (variant === 'flat') {
if (time !== undefined) flatSegments.push(formatMessageClock(time, t, day))
if (runMs !== undefined) flatSegments.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) }))
flatSegments.push(t('message.turnUsage.consumed', { total }))
if (cacheHit !== null) flatSegments.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit }))
if (tokensPerSecond !== undefined) {
flatSegments.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) }))
}
if (ttftMs !== undefined) flatSegments.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) }))
}
const hasTimeFacts = runMs !== undefined || tokensPerSecond !== undefined || ttftMs !== undefined
return (
<span ref={rootRef} className={css.root}>
<button
type="button"
className={css.trigger}
className={variant === 'flat' ? css.flatTrigger : css.trigger}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<IconDataOutline16 />
<span className={css.label}>
{cacheHit === null
? t('message.turnUsage.consumed', { total })
: `${t('message.turnUsage.consumed', { total })} · ${t('message.turnUsage.cacheHitRate', { percent: cacheHit })}`}
</span>
{variant === 'flat' ? flatSegments.map((segment, index) => (
<Fragment key={index}>
{index > 0 && <span className={css.flatDot} aria-hidden>·</span>}
{segment}
</Fragment>
)) : (
<>
<IconDatabaseOutline16 />
<span className={css.label}>{t('message.turnUsage.consumed', { total })}</span>
</>
)}
</button>
{open && createPortal(
<div
@ -107,7 +203,14 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
aria-label={t('message.turnUsage.title')}
style={pos ?? MEASURE_STYLE}
>
<div className={css.title}>{t('message.turnUsage.title')}</div>
<div className={css.title}>
<span className={css.titleLabel}>
<IconDatabaseOutline16 />
{t('message.turnUsage.title')}
</span>
<span className={css.titleValue}>{formatExactCount(usage.totalTokens, t)}</span>
</div>
<div className={css.titleRule} aria-hidden />
<dl className={css.details} data-turn-usage-details>
{routes !== '' && (
<>
@ -144,10 +247,61 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
</span>
)}
</dd>
<div className={css.separator} aria-hidden />
<dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
<dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
</dl>
{variant === 'flat' && hasTimeFacts && (
<>
<div className={`${css.title} ${css.timeTitle}`}>
<span className={css.titleLabel}>
<IconClockOutline16 />
{t('message.turnTime.title')}
</span>
</div>
<div className={css.titleRule} aria-hidden />
<TimeDetails runMs={runMs} tokensPerSecond={tokensPerSecond} ttftMs={ttftMs} t={t} />
</>
)}
</div>,
document.body,
)}
</span>
)
}
/**
* Turn-time IconActions pill with a click-open Turn-time details dialog.
* @param props - Turn timing facts and locale seat.
* @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger.
*/
export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePanelProps) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
return (
<span ref={rootRef} className={css.root}>
<button
type="button"
className={css.trigger}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<IconClockOutline16 />
<span className={css.label}>{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}</span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={css.panel}
role="dialog"
aria-label={t('message.turnTime.title')}
style={pos ?? MEASURE_STYLE}
>
<div className={css.title}>
<span className={css.titleLabel}>
<IconClockOutline16 />
{t('message.turnTime.title')}
</span>
</div>
<div className={css.titleRule} aria-hidden />
<TimeDetails runMs={runMs} tokensPerSecond={tokensPerSecond} ttftMs={ttftMs} t={t} />
</div>,
document.body,
)}

View file

@ -101,8 +101,11 @@ export const zh = {
'message.turnUsage.cacheWrite': '缓存写入',
'message.turnUsage.output': '输出',
'message.turnUsage.reasoning': '(其中推理 {tokens})',
'message.turnUsage.total': '总计',
'message.turnUsage.count': '{count} tok',
'message.turnTime.title': '本轮用时和速度',
'message.turnTime.duration': '本轮总用时',
'message.turnTime.speed': '输出速度(TPS)',
'message.turnTime.ttft': '首 token 平均用时(TTFT)',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'command.running': '执行中…',
@ -217,8 +220,11 @@ export const en = {
'message.turnUsage.cacheWrite': 'Cache write',
'message.turnUsage.output': 'Output',
'message.turnUsage.reasoning': ' ({tokens} reasoning)',
'message.turnUsage.total': 'Total',
'message.turnUsage.count': '{count} tok',
'message.turnTime.title': 'Turn time and speed',
'message.turnTime.duration': 'Total run time',
'message.turnTime.speed': 'Tokens per second (TPS)',
'message.turnTime.ttft': 'Average time to first token (TTFT)',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'command.running': 'Running…',

View file

@ -1562,19 +1562,71 @@ describe('ChatView', () => {
}]]),
})
const view = render(<h.ChatView {...h.props} />)
// First-step ttft (1.2s) plus 100 tokens over 5s of decode stay plain
// text; the usage pill in the icon row is the only clickable trigger.
// The usage pill carries the compact total; cache hit stays dialog-only.
const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ })
expect(trigger.textContent).toBe('用量 10.1K tok · 缓存命中 49.4%')
expect(view.getByText(/用时 19秒/).textContent)
.toMatch(/^.+ · 用时 19秒 · 速度 20 tok\/s · 首 token 1\.2秒$/)
expect(trigger.textContent).toBe('用量 10.1K tok')
expect(view.queryByRole('dialog')).toBeNull()
fireEvent.click(trigger)
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('本轮用量')
expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok')
expect(dialog.textContent).toContain('缓存命中49.4%')
expect(dialog.textContent).toContain('未缓存输入5,060 tok')
expect(dialog.textContent).toContain('总计10,100 tok')
fireEvent.keyDown(document, { key: 'Escape' })
// The time pill carries the run time; first-step ttft (1.2s) and 100
// tokens over 5s of decode move into its dialog.
const timeTrigger = view.getByRole('button', { name: /用时 19秒/ })
expect(timeTrigger.textContent).toBe('用时 19秒')
expect(view.queryByText(/速度 20 tok\/s|首 token/)).toBeNull()
fireEvent.click(timeTrigger)
const timeDialog = view.getByRole('dialog')
expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度')
expect(timeDialog.textContent).toContain('本轮总用时19秒')
expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s')
expect(timeDialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒')
})
it('TEMPORARY: ?usage-variant=flat renders the whole meta line as the dialog trigger', () => {
window.history.replaceState(null, '', '?usage-variant=flat')
try {
const first: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'draft' }],
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
usage: { outputTokens: 40 },
}
const second: AssistantMessageNode = {
kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
usage: { outputTokens: 60 },
}
const h = makeHarness({
nodes: [user(1, 'hi'), first, second],
turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
turnEnds: new Map([[1, 20]]),
turnUsages: new Map([[1, {
uncachedInputTokens: 5_060,
cacheReadTokens: 4_940,
outputTokens: 100,
totalTokens: 10_100,
}]]),
})
const view = render(<h.ChatView {...h.props} />)
const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ })
// Separator dots live in spaced spans, so textContent carries bare `·`.
expect(trigger.textContent)
.toMatch(/^.+·用时 19秒·用量 10\.1K tok·缓存命中 49\.4%·速度 20 tok\/s·首 token 1\.2秒$/)
// The flat trigger absorbs the meta line, so no plain copy remains.
expect(view.getByText(/用时 19秒/)).toBe(trigger)
fireEvent.click(trigger)
const dialog = view.getByRole('dialog')
expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok')
expect(dialog.textContent).toContain('本轮用时和速度')
expect(dialog.textContent).toContain('本轮总用时19秒')
expect(dialog.textContent).toContain('输出速度(TPS)20 tok/s')
expect(dialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒')
} finally {
window.history.replaceState(null, '', window.location.pathname)
}
})
it('withholds the usage-details trigger when turn usage is outside the window', () => {
@ -1589,9 +1641,9 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 20]]),
})
const view = render(<h.ChatView {...h.props} />)
// Timing facts stay visible as plain text, but with no usage in the
// window there is no pill to click.
expect(view.getByText(/首 token/)).toBeTruthy()
// Timing facts keep their pill, but with no usage in the window there is
// no usage pill to click.
expect(view.getByRole('button', { name: /用时/ })).toBeTruthy()
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
})

View file

@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx'
import { TurnTimePanel, TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx'
import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
import { en } from '../src/client/locale.ts'
@ -26,7 +26,7 @@ describe('TurnUsagePanel', () => {
const view = render(<TurnUsagePanel usage={usage} t={t} />)
const trigger = view.getByRole('button')
expect(trigger.textContent).toBe('Usage 15.8K tok · Cache hit 49.4%')
expect(trigger.textContent).toBe('Usage 15.8K tok')
expect(trigger.querySelector('svg')).not.toBeNull()
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
@ -36,9 +36,9 @@ describe('TurnUsagePanel', () => {
expect(trigger.getAttribute('aria-expanded')).toBe('true')
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('Turn usage')
// Portaled out of the trigger's row, with a visible heading above the rows.
// Portaled out of the trigger's row, with a heading row carrying the total.
expect(dialog.parentElement).toBe(document.body)
expect(dialog.firstChild?.textContent).toBe('Turn usage')
expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok')
const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement
expect(details).toBeTruthy()
expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
@ -47,7 +47,7 @@ describe('TurnUsagePanel', () => {
expect(details.textContent).toContain('Cached input4,940 tok')
expect(details.textContent).toContain('Cache write0 tok')
expect(details.textContent).toContain('Output5,800 tok (42 tok reasoning)')
expect(details.textContent).toContain('Total15,800 tok')
expect(details.textContent).not.toContain('Total')
})
it('omits unavailable optional facts instead of inventing values', () => {
@ -77,9 +77,9 @@ describe('TurnUsagePanel', () => {
}
const view = render(<TurnUsagePanel usage={usage} t={t} />)
const trigger = view.getByRole('button')
// The pill carries the compact total and cache-hit rate; exact token
// The pill carries the compact total; cache-hit rate and exact token
// counts stay in the dialog.
expect(trigger.textContent).toBe('Usage 1.1K tok · Cache hit 99.9%')
expect(trigger.textContent).toBe('Usage 1.1K tok')
fireEvent.click(trigger)
const dialog = view.getByRole('dialog')
@ -95,4 +95,88 @@ describe('TurnUsagePanel', () => {
fireEvent.pointerDown(document.body)
expect(view.queryByRole('dialog')).toBeNull()
})
it('TEMPORARY flat variant: the whole meta line triggers a dialog with usage and time sections', () => {
const usage: TurnTokenUsage = {
uncachedInputTokens: 5_060,
cacheReadTokens: 4_940,
outputTokens: 5_800,
totalTokens: 15_800,
}
const view = render(
<TurnUsagePanel
usage={usage}
variant="flat"
time={Date.now()}
runMs={19_000}
tokensPerSecond={20}
ttftMs={1_200}
t={t}
/>,
)
const trigger = view.getByRole('button')
// Separator dots live in spaced spans, so textContent carries bare `·`.
expect(trigger.textContent)
.toMatch(/^.+·Ran for 19s·Usage 15\.8K tok·Cache hit 49\.4%·20 tok\/s·TTFT 1\.2s$/)
expect(trigger.querySelector('svg')).toBeNull()
fireEvent.click(trigger)
const dialog = view.getByRole('dialog')
expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok')
expect(dialog.textContent).toContain('Turn time and speed')
const timeDetails = dialog.querySelector('[data-turn-time-details]') as HTMLElement
expect(timeDetails.textContent).toContain('Total run time19s')
expect(timeDetails.textContent).toContain('Tokens per second (TPS)20 tok/s')
expect(timeDetails.textContent).toContain('Average time to first token (TTFT)1.2s')
})
it('TEMPORARY flat variant: absent time facts leave no Turn-time section', () => {
const usage: TurnTokenUsage = {
uncachedInputTokens: 120,
outputTokens: 30,
totalTokens: 150,
}
const view = render(<TurnUsagePanel usage={usage} variant="flat" t={t} />)
const trigger = view.getByRole('button')
expect(trigger.textContent).toBe('Usage 150 tok')
fireEvent.click(trigger)
const dialog = view.getByRole('dialog')
expect(dialog.textContent).not.toContain('Turn time')
expect(dialog.querySelector('[data-turn-time-details]')).toBeNull()
})
})
describe('TurnTimePanel', () => {
it('shows a clock-and-duration pill and opens the time dialog on click', () => {
const view = render(
<TurnTimePanel runMs={19_000} tokensPerSecond={20} ttftMs={1_200} t={t} />,
)
const trigger = view.getByRole('button')
expect(trigger.textContent).toBe('Ran for 19s')
expect(trigger.querySelector('svg')).not.toBeNull()
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
expect(view.queryByRole('dialog')).toBeNull()
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('Turn time and speed')
expect(dialog.parentElement).toBe(document.body)
const details = dialog.querySelector('[data-turn-time-details]') as HTMLElement
expect(details.textContent).toContain('Total run time19s')
expect(details.textContent).toContain('Tokens per second (TPS)20 tok/s')
expect(details.textContent).toContain('Average time to first token (TTFT)1.2s')
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.queryByRole('dialog')).toBeNull()
})
it('omits unrecorded speed and TTFT rows', () => {
const view = render(<TurnTimePanel runMs={3_000} t={t} />)
fireEvent.click(view.getByRole('button'))
const dialog = view.getByRole('dialog')
expect(dialog.textContent).toContain('Total run time3s')
expect(dialog.textContent).not.toContain('Tokens per second')
expect(dialog.textContent).not.toContain('Time to first token')
})
})

View file

@ -761,6 +761,23 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** IconDataOutline16 without its gear: a three-tier database cylinder. */
export const IconDatabaseOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<ellipse cx="8" cy="3.6" rx="5.75" ry="2.4" stroke="currentColor" strokeWidth="1.25" />
<path d="M2.25 3.6V12.3A5.75 2.4 0 0 0 13.75 12.3V3.6" stroke="currentColor" strokeWidth="1.25" />
<path d="M2.25 7.95A5.75 2.4 0 0 0 13.75 7.95" stroke="currentColor" strokeWidth="1.25" />
</svg>
)
/** Thin-stroke clock: outlined dial with square-cut hour and minute hands. */
export const IconClockOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8" cy="8" r="6.375" stroke="currentColor" strokeWidth="1.25" />
<path d="M8 4.4V8.3L10.7 9.85" stroke="currentColor" strokeWidth="1.25" />
</svg>
)
/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */
export const IconSendOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">

View file

@ -49,7 +49,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -72,7 +72,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "Thought for a while":
- text: Thought for a while
- img
@ -85,7 +88,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. {{clock}}
- button "Copy":
- img
- button "1 tool call" [expanded]:
@ -104,7 +110,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
- img
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
- img
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."':
- img
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."':
- img
- img

View file

@ -44,7 +44,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -97,7 +97,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "System prompt":
- img
- img
@ -217,7 +220,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "Back to bottom":
- img
- textbox "Message or run a task... / commands, @ files or sessions"

View file

@ -42,7 +42,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "System prompt":
- img
- img
@ -107,7 +110,10 @@
- button "Branch into a new conversation":
- img
- tooltip "Branch into a new conversation"
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -33,7 +33,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -29,7 +29,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -38,7 +38,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -31,7 +31,11 @@
- img
- button "Branch into a new conversation" [disabled]:
- img
- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- text: Available only on the last message of a completed turn
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- button "Read a.txt":
- img
- img
@ -58,7 +62,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -33,7 +33,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -54,7 +54,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -34,7 +34,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -46,7 +46,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -33,7 +33,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "2 queued messages" [expanded]
- list:
- listitem:

View file

@ -29,7 +29,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "2 queued messages" [expanded]
- list:
- listitem:

View file

@ -47,7 +47,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img

View file

@ -47,7 +47,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img

View file

@ -47,7 +47,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img

View file

@ -29,7 +29,10 @@
- img
- button "Branch into a new conversation":
- img
- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: 7/25 {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img

View file

@ -47,7 +47,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{date}} {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -39,7 +39,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -31,7 +31,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -44,7 +44,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "System prompt":
- img
- img
@ -68,7 +71,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- button "System prompt":
- img
- img
@ -56,7 +59,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,10 +28,13 @@
- img
- button "Branch into a new conversation":
- img
- button "Usage 15.8K tok · Cache hit 49.7%":
- button "Usage 15.8K tok":
- img
- text: Usage 15.8K tok · Cache hit 49.7%
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- text: Usage 15.8K tok
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -41,10 +41,13 @@
- img
- button "Branch into a new conversation":
- img
- button "Usage 15.8K tok · Cache hit 49.7%":
- button "Usage 15.8K tok":
- img
- text: Usage 15.8K tok · Cache hit 49.7%
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- text: Usage 15.8K tok
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -30,7 +30,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -28,10 +28,13 @@
- img
- button "Branch into a new conversation":
- img
- button "Usage 15.8K tok · Cache hit 49.7%" [expanded]:
- button "Usage 15.8K tok" [expanded]:
- img
- text: Usage 15.8K tok · Cache hit 49.7%
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- text: Usage 15.8K tok
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,7 +36,10 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img

View file

@ -36,4 +36,7 @@
- img
- button "Branch into a new conversation":
- img
- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Ran for {{duration}}":
- img
- text: Ran for {{duration}}
- text: {{clock}}