From bb1df10c695db1af8c3e388f5a317ab5a8cd9ab8 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Thu, 27 Aug 2026 18:22:21 +0800
Subject: [PATCH 1/8] feat(ui-chat): collapse the turn tail into one clickable
meta line
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the two-row footer (TurnUsageDisclosure + icon-row clock chrome)
with a single whole-line trigger (clock · run time · turn usage · cache
hit · speed · TTFT) that opens a per-Turn usage dialog. The latest turn
keeps its tail always visible; older turns reveal the whole row on
hover/focus. Turns without usage data keep the plain clock line with
identical spacing.
---
apps/web/tests/turn-tail-actions.e2e.ts | 26 ++--
.../client/chat/MessageIconActions.module.css | 28 +++-
.../src/client/chat/MessageIconActions.tsx | 35 ++---
.../client/chat/TurnTailNodeView.module.css | 7 -
.../src/client/chat/TurnTailNodeView.tsx | 46 +++---
.../chat/TurnUsageDisclosure.module.css | 88 -----------
.../src/client/chat/TurnUsageDisclosure.tsx | 86 ----------
.../src/client/chat/TurnUsagePanel.module.css | 99 ++++++++++++
.../src/client/chat/TurnUsagePanel.tsx | 147 ++++++++++++++++++
packages/client/ui-chat/src/client/locale.ts | 20 +++
.../chat-font-axis-styles.client.spec.ts | 63 ++++++--
.../tests/chat-snapshot-fixture.client.ts | 5 +
.../turn-usage-disclosure.client.spec.tsx | 76 ---------
.../tests/turn-usage-panel.client.spec.tsx | 98 ++++++++++++
.../client/MessageFeedbackActions.module.css | 6 +-
.../tests/styles.client.spec.ts | 2 +-
.../web/feedback-command/ack.expected.md | 2 +-
snapshots/web/message-actions/ui.expected.md | 2 +-
.../web/queue-actions/preserved.expected.md | 2 +-
.../seeded-history/command-row.expected.md | 2 +-
.../seeded-history/feedback-row.expected.md | 2 +-
snapshots/web/seeded-history/ui.expected.md | 2 +-
snapshots/web/workflow-run/ui.expected.md | 2 +-
23 files changed, 510 insertions(+), 336 deletions(-)
delete mode 100644 packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
delete mode 100644 packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
create mode 100644 packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
create mode 100644 packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
delete mode 100644 packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
create mode 100644 packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index c207d9278b..db2840a29e 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/apps/web/tests/turn-tail-actions.e2e.ts
@@ -170,18 +170,22 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const { settled } = await sendPrompt(120_000)
await settled
- const disclosure = page.getByRole('button', { name: /Turn usage/ })
- await expect.poll(() => disclosure.count(), { timeout: 10_000 }).toBe(1)
- expect(await disclosure.getAttribute('aria-expanded')).toBe('false')
- expect(await page.getByText('15.8K tok · Cache hit 49.7%', { exact: true }).count()).toBe(1)
+ const trigger = page.getByRole('button', { name: /Cache hit 49\.7%/ })
+ await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1)
+ expect(await trigger.getAttribute('aria-expanded')).toBe('false')
+ expect(await trigger.textContent()).toMatch(/^.+ · Ran for .+ · Turn usage 15\.8K tok · Cache hit 49\.7% · \d+ tok\/s · TTFT \S+$/)
+ expect(await page.getByRole('dialog').count()).toBe(0)
- await disclosure.click()
- expect(await disclosure.getAttribute('aria-expanded')).toBe('true')
- expect(await page.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
- expect(await page.getByText('7,891 tok', { exact: true }).count()).toBe(1)
- expect(await page.getByText('7,808 tok', { exact: true }).count()).toBe(1)
- expect(await page.getByText('112 tok (42 tok reasoning)', { exact: true }).count()).toBe(1)
- expect(await page.getByText('15,811 tok', { exact: true }).count()).toBe(1)
+ await trigger.click()
+ expect(await trigger.getAttribute('aria-expanded')).toBe('true')
+ const dialog = page.getByRole('dialog', { name: 'Turn usage' })
+ expect(await dialog.count()).toBe(1)
+ expect(await dialog.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
+ expect(await dialog.getByText('7,891 tok', { exact: true }).count()).toBe(1)
+ 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('49.7%', { exact: true }).count()).toBe(1)
+ expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1)
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(USAGE_EXPANDED_EXPECTED, expanded, MODE)
diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
index c6f6bab161..5e31ea2e2f 100644
--- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
+++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
@@ -5,7 +5,7 @@
.actions {
display: flex;
align-items: center;
- gap: 10px;
+ gap: 8px;
height: calc(28px + var(--dsh-content-font-delta, 0px));
}
@@ -18,9 +18,11 @@
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. */
.timeEnd {
padding-left: 12px;
- font-size: var(--dsh-content-font-size, 14px);
+ 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;
@@ -28,7 +30,7 @@
/* Separator between the clock and the run-time label (time · Ran for 15s). */
.runTimeDot {
- margin: 0 10px;
+ margin: 0 5px;
}
/* Message containers opt in with data-time-hover-root: the time label fades
@@ -44,6 +46,20 @@
[data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
opacity: 1;
}
+
+ /* Turn tails opt in with data-actions-reveal='hover' instead: earlier turns
+ reveal the entire row (icons and text) on hover or focus, while the latest
+ turn ('always') keeps its row visible. Same opacity gate as the time
+ labels, so layout never moves and no-hover devices keep the row visible. */
+ [data-actions-reveal='hover'] .actions {
+ opacity: 0;
+ transition: opacity 80ms ease;
+ }
+
+ [data-actions-reveal='hover']:hover .actions,
+ [data-actions-reveal='hover']:focus-within .actions {
+ opacity: 1;
+ }
}
.action {
@@ -60,11 +76,11 @@
cursor: pointer;
}
-/* The 16px action glyphs follow the same px delta as the text they serve;
+/* The 15px action glyphs follow the same px delta as the text they serve;
the CSS edge overrides each svg's own width/height attributes. */
.action svg {
- width: calc(16px + var(--dsh-content-font-delta, 0px));
- height: calc(16px + var(--dsh-content-font-delta, 0px));
+ width: calc(15px + var(--dsh-content-font-delta, 0px));
+ height: calc(15px + var(--dsh-content-font-delta, 0px));
}
.action:hover {
diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
index 7d817d6ab5..8735850b30 100644
--- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
+++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
@@ -6,7 +6,7 @@ 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, formatRunDuration } from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -17,10 +17,6 @@ export interface MessageIconActionsProps {
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 first-step TTFT in ms, appended as `· TTFT 1.2s`; omitted when unrecorded. */
- ttftMs?: number | undefined
- /** Turn decode throughput, appended as `· 34 tok/s`; omitted when unrecorded. */
- tokensPerSecond?: number | undefined
/** Clock before icons (user) or after (assistant). */
clock: 'start' | 'end'
/** Fork the session at this message; omission hides the branch action. */
@@ -34,6 +30,12 @@ export interface MessageIconActionsProps {
* built-in copy and branch controls.
*/
extraActions?: ReactNode
+ /**
+ * Whole-line meta chrome (the TurnUsagePanel trigger) rendered in the `end`
+ * clock's seat; when present the caller omits `time`/`runMs` so the trigger
+ * owns the entire line.
+ */
+ usageDetails?: ReactNode
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -44,8 +46,8 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
- text, time, runMs, ttftMs, tokensPerSecond, clock, onBranch, branchUnavailable = false, className,
- extraActions, t,
+ text, time, runMs, clock, onBranch, branchUnavailable = false, className,
+ extraActions, usageDetails, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -77,7 +79,7 @@ export function MessageIconActions({
}, [copied, text])
// The dot is decorative and stays hidden, but its margins separate the
// readings only on screen: without the flanking spaces a reader hears one
- // run-on string ("Ran for 13sTTFT 0.2s12 tok/s") instead of three facts.
+ // run-on string ("8/26 22:08Ran for 13s") instead of two facts.
const clockEl = time === undefined ? null : (
{formatMessageClock(time, t, day)}
@@ -89,22 +91,6 @@ export function MessageIconActions({
{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
>
)}
- {ttftMs !== undefined && (
- <>
- {' '}
- ·
- {' '}
- {t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
- >
- )}
- {tokensPerSecond !== undefined && (
- <>
- {' '}
- ·
- {' '}
- {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
- >
- )}
)
return (
@@ -136,6 +122,7 @@ export function MessageIconActions({
{t('message.branchUnavailable')}
)}
{clock === 'end' ? clockEl : null}
+ {clock === 'end' ? usageDetails : null}
)
}
diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
index 65d9138b77..831e6e212b 100644
--- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
+++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
@@ -4,13 +4,6 @@
gap: 16px;
}
-.footer {
- display: flex;
- min-width: 0;
- flex-direction: column;
- gap: 4px;
-}
-
.actions {
margin-left: -6px;
}
diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
index cc715bf450..b314779168 100644
--- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
@@ -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 { TurnUsageDisclosure } from './TurnUsageDisclosure.tsx'
+import { TurnUsagePanel } from './TurnUsagePanel.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
@@ -16,6 +16,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
const data = node.data
const hasLaterChatNode = useChat(snapshot =>
snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
+ const isLatestTurn = useChat(snapshot => snapshot.timeline.turnOrder.at(-1) === data.turn)
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
@@ -34,24 +35,33 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
? null
: renderSlot('conversation.chat.assistant-actions', { messageId })
return (
-
+
{tail}
-
- {data.tokenUsage === undefined ? null : }
- { forkAt(closing.finalNode.seq) }}
- branchUnavailable={data.branchUnavailable || hasLaterChatNode}
- className={css.actions}
- extraActions={assistantActions}
- t={t}
- />
-
+
{ forkAt(closing.finalNode.seq) }}
+ branchUnavailable={data.branchUnavailable || hasLaterChatNode}
+ className={css.actions}
+ extraActions={assistantActions}
+ usageDetails={data.tokenUsage === undefined ? null : (
+
+ )}
+ t={t}
+ />
)
})
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
deleted file mode 100644
index ba96155504..0000000000
--- a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
+++ /dev/null
@@ -1,88 +0,0 @@
-.root {
- min-width: 0;
-}
-
-.root[data-open] {
- padding-bottom: 4px;
-}
-
-.root [data-disclosure-row]:focus-visible {
- border-radius: 6px;
- outline: 2px solid var(--dsw-alias-label-tertiary);
- outline-offset: -2px;
-}
-
-.chevron {
- color: var(--dsw-alias-label-secondary);
-}
-
-.separator {
- flex: none;
- width: 2px;
- height: 2px;
- margin: 0 8px;
- border-radius: 1px;
- background: var(--dsw-alias-label-caption);
-}
-
-.summary {
- min-width: 0;
- overflow: hidden;
- 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));
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.details {
- display: grid;
- grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
- gap: 6px 16px;
- box-sizing: border-box;
- /* Indent under the title, which starts at leading (16 + delta) + gap 6. */
- width: calc(100% - 22px - var(--dsh-content-font-delta, 0px));
- margin: 4px 0 0 calc(22px + var(--dsh-content-font-delta, 0px));
- padding: 10px 16px 12px 12px;
- border-radius: 8px;
- background: var(--dsw-alias-markdown-code-block);
- color: var(--dsw-alias-label-tertiary);
- font-size: 12px;
- line-height: 18px;
-}
-
-.details dt,
-.details dd {
- min-width: 0;
- margin: 0;
-}
-
-.details dd {
- color: var(--dsw-alias-label-secondary);
- font-variant-numeric: tabular-nums;
- text-align: right;
-}
-
-.details .route {
- overflow-wrap: anywhere;
-}
-
-.reasoning {
- color: var(--dsw-alias-label-tertiary);
- white-space: nowrap;
-}
-
-.totalLabel,
-.details .totalValue {
- padding-top: 6px;
- border-top: 1px solid var(--dsw-alias-separator-primary);
- color: var(--dsw-alias-label-primary);
-}
-
-@media (max-width: 480px) {
- .details {
- grid-template-columns: minmax(72px, auto) minmax(0, 1fr);
- gap-inline: 10px;
- }
-}
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx b/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
deleted file mode 100644
index 8d79b44f4d..0000000000
--- a/packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
+++ /dev/null
@@ -1,86 +0,0 @@
-import { useState } from 'react'
-import { DisclosureRow, IconDataOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
-import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
-import type { ChatViewSlotProps } from '../contract/slots.ts'
-import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
-import css from './TurnUsageDisclosure.module.css'
-
-export interface TurnUsageDisclosureProps {
- usage: TurnTokenUsage
- t: ChatViewSlotProps['t']
-}
-
-function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
- return t('message.turnUsage.count', { count: formatTokens(value, t) })
-}
-
-function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
- return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
-}
-
-/** Compact per-Turn usage summary with an opt-in bucket breakdown. */
-export function TurnUsageDisclosure({ usage, t }: TurnUsageDisclosureProps) {
- const [open, setOpen] = useState(false)
- const cacheHit = usage.cacheReadTokens === undefined
- ? null
- : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
- const total = formatCompactCount(usage.totalTokens, t)
- const summary = cacheHit === null
- ? total
- : t('message.turnUsage.summaryWithCache', { total, percent: cacheHit })
- const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
-
- return (
-
}
- title={t('message.turnUsage.title')}
- open={open}
- expandable
- onToggle={() => { setOpen(value => !value) }}
- expandOnRowClick
- keepContentWhenOpen
- collapsedContent={(
- <>
-
-
{summary}
- >
- )}
- className={css.root}
- chevronClassName={css.chevron}
- >
-
- {routes !== '' && (
- <>
- - {t('message.turnUsage.model')}
- - {routes}
- >
- )}
- - {t('message.turnUsage.input')}
- - {formatExactCount(usage.uncachedInputTokens, t)}
- {usage.cacheReadTokens !== undefined && (
- <>
- - {t('message.turnUsage.cacheRead')}
- - {formatExactCount(usage.cacheReadTokens, t)}
- >
- )}
- {usage.cacheWriteTokens !== undefined && (
- <>
- - {t('message.turnUsage.cacheWrite')}
- - {formatExactCount(usage.cacheWriteTokens, t)}
- >
- )}
- - {t('message.turnUsage.output')}
- -
- {formatExactCount(usage.outputTokens, t)}
- {usage.reasoningTokens !== undefined && (
-
- {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
-
- )}
-
- - {t('message.turnUsage.total')}
- - {formatExactCount(usage.totalTokens, t)}
-
-
- )
-}
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
new file mode 100644
index 0000000000..1c6ac61142
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
@@ -0,0 +1,99 @@
+/* Whole-line meta trigger on the finalized footer row, plus its click-open
+ Turn-details dialog (menu surface: r12, inverted hairline, shadow-lv3 —
+ ContextMeter's panel skin). */
+
+.root {
+ position: relative;
+ display: inline-flex;
+ min-width: 0;
+ /* Same 12px inset the plain clock label (.timeEnd) keeps from the icons. */
+ margin-left: 12px;
+}
+
+/* Same text treatment as the row's clock label (secondary tier, one step
+ under the body size); hover brightens one step to signal the cluster is
+ clickable. min-width 0 + hidden overflow let the one-line cluster shrink
+ with the chat column and ellipsize instead of widening it. */
+.trigger {
+ min-width: 0;
+ 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;
+}
+
+.trigger:hover,
+.trigger[aria-expanded='true'] {
+ color: var(--dsw-alias-label-secondary);
+}
+
+/* Same segment separators as the plain clock row's runTimeDot, so the
+ clickable and plain forms of the meta line space identically. */
+.dot {
+ margin: 0 5px;
+}
+
+.panel {
+ position: absolute;
+ bottom: calc(100% + 8px);
+ left: 0;
+ z-index: 100;
+ box-sizing: border-box;
+ /* Size to the widest row so provider/model stays on one line, within a cap. */
+ width: max-content;
+ min-width: 300px;
+ max-width: min(440px, calc(100vw - 24px));
+ padding: 12px;
+ border: 1px solid var(--dsw-alias-border-inverted);
+ border-radius: 12px;
+ background: var(--dsw-specific-menu);
+ box-shadow: var(--dsw-shadow-lv3);
+ font-size: 12px;
+ line-height: 18px;
+ color: var(--dsw-alias-label-secondary);
+ cursor: default;
+}
+
+.details {
+ display: grid;
+ grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
+ gap: 6px 16px;
+ margin: 0;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+.details dt,
+.details dd {
+ min-width: 0;
+ margin: 0;
+}
+
+.details dd {
+ color: var(--dsw-alias-label-secondary);
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+}
+
+.details .route {
+ overflow-wrap: anywhere;
+}
+
+.reasoning {
+ color: var(--dsw-alias-label-tertiary);
+ white-space: nowrap;
+}
+
+.totalLabel,
+.details .totalValue {
+ padding-top: 8px;
+ border-top: 1px solid var(--dsw-alias-border-l2);
+ color: var(--dsw-alias-label-primary);
+ font-weight: 500;
+}
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
new file mode 100644
index 0000000000..65709cebb8
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
@@ -0,0 +1,147 @@
+// Single-row Turn chrome: the finalized footer's whole meta line (clock,
+// duration, turn total, throughput, TTFT, cache-hit) as one inline trigger
+// whose click opens the per-Turn details dialog.
+
+import { Fragment, useEffect, useRef, useState } from 'react'
+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
+ /** Turn-close instant (unix epoch ms) shown as the line's leading clock. */
+ time: number
+ /** Turn elapsed run time in ms, shown as `用时 6秒`; omitted when unrecorded. */
+ runMs?: number | undefined
+ /** Turn first-step TTFT in ms, shown as `TTFT 1.2s`; omitted when unrecorded. */
+ ttftMs?: number | undefined
+ /** Turn decode throughput, shown as `34 tok/s`; omitted when unrecorded. */
+ tokensPerSecond?: number | undefined
+ /** The owning view's locale seat, passed down as a plain prop. */
+ t: ChatViewSlotProps['t']
+}
+
+function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
+ return t('message.turnUsage.count', { count: formatTokens(value, t) })
+}
+
+function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
+ return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
+}
+
+/**
+ * Whole-line clickable meta cluster with a click-open Turn-details dialog.
+ * @param props - Turn usage buckets, close time, optional timing metrics, locale seat.
+ * @returns The trigger and, while open, its anchored dialog.
+ */
+export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t }: TurnUsagePanelProps) {
+ const [open, setOpen] = useState(false)
+ const rootRef = useRef
(null)
+ const day = useCalendarDay()
+
+ // Outside click / Escape close, one document listener while open (ContextMeter's pattern).
+ useEffect(() => {
+ if (!open) return
+ const onPointerDown = (e: PointerEvent): void => {
+ if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
+ setOpen(false)
+ }
+ const onKeyDown = (e: KeyboardEvent): void => {
+ if (e.key === 'Escape') setOpen(false)
+ }
+ document.addEventListener('pointerdown', onPointerDown)
+ document.addEventListener('keydown', onKeyDown)
+ return () => {
+ document.removeEventListener('pointerdown', onPointerDown)
+ document.removeEventListener('keydown', onKeyDown)
+ }
+ }, [open])
+
+ 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 clock = formatMessageClock(time, t, day)
+ const metrics: string[] = [clock]
+ if (runMs !== undefined) metrics.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) }))
+ metrics.push(t('message.turnUsage.consumed', { total }))
+ if (cacheHit !== null) metrics.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit }))
+ if (tokensPerSecond !== undefined) {
+ metrics.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) }))
+ }
+ if (ttftMs !== undefined) metrics.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) }))
+
+ return (
+
+
+ {open && (
+
+
+ {routes !== '' && (
+ <>
+ - {t('message.turnUsage.model')}
+ - {routes}
+ >
+ )}
+ - {t('message.turnUsage.input')}
+ - {formatExactCount(usage.uncachedInputTokens, t)}
+ {usage.cacheReadTokens !== undefined && (
+ <>
+ - {t('message.turnUsage.cacheRead')}
+ - {formatExactCount(usage.cacheReadTokens, t)}
+ >
+ )}
+ {usage.cacheWriteTokens !== undefined && (
+ <>
+ - {t('message.turnUsage.cacheWrite')}
+ - {formatExactCount(usage.cacheWriteTokens, t)}
+ >
+ )}
+ - {t('message.turnUsage.output')}
+ -
+ {formatExactCount(usage.outputTokens, t)}
+ {usage.reasoningTokens !== undefined && (
+
+ {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
+
+ )}
+
+ {cacheHit !== null && (
+ <>
+ - {t('message.turnDetails.cacheHit')}
+ - {`${cacheHit}%`}
+ >
+ )}
+ - {t('message.turnUsage.total')}
+ - {formatExactCount(usage.totalTokens, t)}
+
+
+ )}
+
+ )
+}
diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts
index 3c211c9d05..2caec97796 100644
--- a/packages/client/ui-chat/src/client/locale.ts
+++ b/packages/client/ui-chat/src/client/locale.ts
@@ -92,6 +92,16 @@ export const zh = {
'message.tokensPerSecond': '{tps} tok/s',
'message.turnUsage.title': '本轮用量',
'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%',
+ 'message.turnUsage.consumed': '本轮用量 {total}',
+ 'message.turnUsage.speed': '速度 {tps} tok/s',
+ 'message.turnUsage.cacheHitRate': '缓存命中率 {percent}%',
+ 'message.turnDetails.title': '本轮详情',
+ 'message.turnDetails.time': '时间',
+ 'message.turnDetails.duration': '用时',
+ 'message.turnDetails.ttft': '首 token',
+ 'message.turnDetails.speed': '速度',
+ 'message.turnDetails.usage': '用量',
+ 'message.turnDetails.cacheHit': '缓存命中率',
'message.turnUsage.model': '提供方 / 模型',
'message.turnUsage.input': '未缓存输入',
'message.turnUsage.cacheRead': '缓存读取',
@@ -205,6 +215,16 @@ export const en = {
'message.tokensPerSecond': '{tps} tok/s',
'message.turnUsage.title': 'Turn usage',
'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%',
+ 'message.turnUsage.consumed': 'Turn usage {total}',
+ 'message.turnUsage.speed': '{tps} tok/s',
+ 'message.turnUsage.cacheHitRate': 'Cache hit {percent}%',
+ 'message.turnDetails.title': 'Turn details',
+ 'message.turnDetails.time': 'Time',
+ 'message.turnDetails.duration': 'Duration',
+ 'message.turnDetails.ttft': 'TTFT',
+ 'message.turnDetails.speed': 'Speed',
+ 'message.turnDetails.usage': 'Usage',
+ 'message.turnDetails.cacheHit': 'Cache hit',
'message.turnUsage.model': 'Provider / model',
'message.turnUsage.input': 'Uncached input',
'message.turnUsage.cacheRead': 'Cached input',
diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
index d0b8c9cb3a..b11c53e87b 100644
--- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
+++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
@@ -45,14 +45,17 @@ describe('chat flow font-size axis', () => {
it('the message clock and action glyphs scale with the text they serve', () => {
const actions = read('MessageIconActions.module.css')
- for (const selector of ['.timeStart', '.timeEnd']) {
- expect(declarationsFrom(actions, selector)).toEqual(expect.arrayContaining([
- 'font-size: var(--dsh-content-font-size, 14px)',
- ]))
- }
+ expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([
+ 'font-size: var(--dsh-content-font-size, 14px)',
+ ]))
+ // The assistant tail's meta line (the whole-line usage trigger) sits one
+ // step under the body size; the user row's clock keeps the body size.
+ expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([
+ 'font-size: var(--dsh-content-font-size-secondary, 13px)',
+ ]))
expect(declarationsFrom(actions, '.action svg')).toEqual(expect.arrayContaining([
- 'width: calc(16px + var(--dsh-content-font-delta, 0px))',
- 'height: calc(16px + var(--dsh-content-font-delta, 0px))',
+ 'width: calc(15px + var(--dsh-content-font-delta, 0px))',
+ 'height: calc(15px + var(--dsh-content-font-delta, 0px))',
]))
})
@@ -80,8 +83,50 @@ describe('chat flow font-size axis', () => {
.toEqual(expect.arrayContaining([`padding: 4px 0 4px ${indent}`]))
expect(declarationsFrom(read('ContextInjectionRow.module.css'), '.body'))
.toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`]))
- expect(declarationsFrom(read('TurnUsageDisclosure.module.css'), '.details'))
- .toEqual(expect.arrayContaining([`margin: 4px 0 0 ${indent}`]))
+ })
+
+ it('the usage-details trigger reads the secondary tier like its clock label', () => {
+ const css = read('TurnUsagePanel.module.css')
+ expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([
+ 'font-size: var(--dsh-content-font-size-secondary, 13px)',
+ 'line-height: calc(24px + var(--dsh-content-font-delta, 0px))',
+ ]))
+ })
+
+ it('the whole-line trigger ellipsizes instead of widening the chat column', () => {
+ // The one-line meta cluster stays nowrap; min-width 0 lets the flex item
+ // shrink with the column and the hidden overflow trims to an ellipsis.
+ const css = read('TurnUsagePanel.module.css')
+ expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([
+ 'min-width: 0',
+ 'white-space: nowrap',
+ 'overflow: hidden',
+ 'text-overflow: ellipsis',
+ ]))
+ expect(declarationsFrom(css, '.root')).toEqual(expect.arrayContaining(['min-width: 0']))
+ })
+
+ it('the clickable and plain meta lines keep identical inset and separator spacing', () => {
+ const panel = read('TurnUsagePanel.module.css')
+ const actions = read('MessageIconActions.module.css')
+ expect(declarationsFrom(panel, '.dot')).toEqual(['margin: 0 5px'])
+ expect(declarationsFrom(actions, '.runTimeDot')).toEqual(['margin: 0 5px'])
+ expect(declarationsFrom(panel, '.root')).toEqual(expect.arrayContaining(['margin-left: 12px']))
+ expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining(['padding-left: 12px']))
+ })
+
+ it('non-latest turn tails hide the whole actions row until hover or focus', () => {
+ // TurnTailNodeView tags its root data-actions-reveal='hover' for every
+ // turn but the latest; the gate lives under @media (hover: hover) so
+ // no-hover devices keep the row visible. 'always' has no rule at all —
+ // absence, not an override, keeps the latest turn's row shown.
+ const css = read('MessageIconActions.module.css')
+ expect(declarationsFrom(css, "[data-actions-reveal='hover'] .actions"))
+ .toEqual(expect.arrayContaining(['opacity: 0']))
+ expect(css).toMatch(
+ /\[data-actions-reveal='hover'\]:hover \.actions,\s*\[data-actions-reveal='hover'\]:focus-within \.actions \{\s*opacity: 1/,
+ )
+ expect(css).not.toContain("[data-actions-reveal='always']")
})
it('the interrupted-turn tag stays fixed like the dense token variants', () => {
diff --git a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
index 39920f0201..311e2545a8 100644
--- a/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
+++ b/packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
@@ -6,6 +6,7 @@ import type {
import type {
ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
+import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
import {
sameTurnNavigationItem, turnNavigationItem,
@@ -179,6 +180,8 @@ export function chatSnapshotFixture(input: {
readonly runningCalls?: readonly RunningToolCall[]
readonly turnTimings?: LegacyConversationSlice['turnTimings']
readonly turnEnds?: LegacyConversationSlice['turnEnds']
+ /** Per-turn usage buckets; production derives these from session events. */
+ readonly turnUsages?: ReadonlyMap | undefined
} = {}, previous?: ChatSnapshot): ChatSnapshot {
const legacy: LegacyConversationSlice = {
nodes: input.nodes ?? EMPTY,
@@ -360,6 +363,7 @@ export function chatSnapshotFixture(input: {
&& location.turn.turn === turnNumber
})
const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber)
+ const tokenUsage = input.turnUsages?.get(turnNumber)
const tailData = {
turn: turnNumber,
seq: endSeq,
@@ -370,6 +374,7 @@ export function chatSnapshotFixture(input: {
|| (preceding.data as ReturnType).finalNode.seq !== closing.finalNode.seq,
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
+ ...tokenUsage === undefined ? {} : { tokenUsage },
}
dataStore.set('turn-tail', tailData)
nodes.push({
diff --git a/packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
deleted file mode 100644
index 23984566f3..0000000000
--- a/packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-// @vitest-environment jsdom
-
-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 { TurnUsageDisclosure } from '../src/client/chat/TurnUsageDisclosure.tsx'
-import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
-import { en } from '../src/client/locale.ts'
-
-const t = makeTranslate(en, commonEn)
-
-afterEach(cleanup)
-
-describe('TurnUsageDisclosure', () => {
- it('shows the exact compact summary and expands into provider facts', () => {
- const usage: TurnTokenUsage = {
- uncachedInputTokens: 5_060,
- cacheReadTokens: 4_940,
- cacheWriteTokens: 0,
- outputTokens: 5_800,
- reasoningTokens: 42,
- totalTokens: 15_800,
- routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
- }
- const view = render()
-
- expect(view.getByText('15.8K tok · Cache hit 49.4%')).toBeTruthy()
- expect(view.queryByRole('definition')).toBeNull()
-
- fireEvent.click(view.getByRole('button'))
- const details = view.container.querySelector('[data-turn-usage-details]') as HTMLElement
- expect(details).toBeTruthy()
- expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
- expect(details.textContent).toContain('Uncached input5,060 tok')
- 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')
- })
-
- it('omits unavailable optional facts instead of inventing values', () => {
- const usage: TurnTokenUsage = {
- uncachedInputTokens: 120,
- outputTokens: 30,
- totalTokens: 150,
- }
- const view = render()
-
- expect(view.getByText('150 tok')).toBeTruthy()
- expect(view.queryByText(/Cache hit/)).toBeNull()
- fireEvent.click(view.getByRole('button'))
- expect(view.queryByText('Provider / model')).toBeNull()
- expect(view.queryByText('Cached input')).toBeNull()
- expect(view.queryByText('Cache write')).toBeNull()
- expect(view.queryByText(/reasoning/)).toBeNull()
- })
-
- it('keeps a partial cache hit below 100 and supports keyboard toggling', () => {
- const usage: TurnTokenUsage = {
- uncachedInputTokens: 1,
- cacheReadTokens: 999,
- outputTokens: 100,
- totalTokens: 1_100,
- }
- const view = render()
- expect(view.getByText('1.1K tok · Cache hit 99.9%')).toBeTruthy()
-
- const disclosure = view.getByRole('button')
- expect(disclosure.getAttribute('aria-expanded')).toBe('false')
- fireEvent.keyDown(disclosure, { key: ' ' })
- expect(disclosure.getAttribute('aria-expanded')).toBe('true')
- fireEvent.keyDown(disclosure, { key: 'Enter' })
- expect(disclosure.getAttribute('aria-expanded')).toBe('false')
- })
-})
diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
new file mode 100644
index 0000000000..d99fc0598f
--- /dev/null
+++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
@@ -0,0 +1,98 @@
+// @vitest-environment jsdom
+
+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 type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
+import { en } from '../src/client/locale.ts'
+
+const t = makeTranslate(en, commonEn)
+
+// Today at 22:08 local, so formatMessageClock renders the bare `HH:mm` form.
+const TIME = new Date().setHours(22, 8, 0, 0)
+
+afterEach(cleanup)
+
+describe('TurnUsagePanel', () => {
+ it('exposes the whole meta line inline and opens the usage dialog on click', () => {
+ const usage: TurnTokenUsage = {
+ uncachedInputTokens: 5_060,
+ cacheReadTokens: 4_940,
+ cacheWriteTokens: 0,
+ outputTokens: 5_800,
+ reasoningTokens: 42,
+ totalTokens: 15_800,
+ routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
+ }
+ const view = render(
+ ,
+ )
+
+ const trigger = view.getByRole('button')
+ expect(trigger.textContent).toBe('22:08 · Ran for 13s · Turn usage 15.8K tok · Cache hit 49.4% · 107 tok/s · TTFT 2s')
+ expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
+ 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 usage')
+ const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement
+ expect(details).toBeTruthy()
+ expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
+ expect(details.textContent).toContain('Uncached input5,060 tok')
+ 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('Cache hit49.4%')
+ expect(details.textContent).toContain('Total15,800 tok')
+ })
+
+ it('omits unavailable optional facts instead of inventing values', () => {
+ const usage: TurnTokenUsage = {
+ uncachedInputTokens: 120,
+ outputTokens: 30,
+ totalTokens: 150,
+ }
+ const view = render()
+
+ const trigger = view.getByRole('button')
+ expect(trigger.textContent).toBe('22:08 · Turn usage 150 tok')
+ expect(view.queryByText(/Cache hit/)).toBeNull()
+ expect(view.queryByText(/TTFT/)).toBeNull()
+ expect(view.queryByText(/tok\/s/)).toBeNull()
+ fireEvent.click(trigger)
+ expect(view.queryByText('Provider / model')).toBeNull()
+ expect(view.queryByText('Cached input')).toBeNull()
+ expect(view.queryByText('Cache write')).toBeNull()
+ expect(view.queryByText(/reasoning/)).toBeNull()
+ })
+
+ it('keeps a partial cache hit below 100 and closes on Escape or outside pointerdown', () => {
+ const usage: TurnTokenUsage = {
+ uncachedInputTokens: 1,
+ cacheReadTokens: 999,
+ outputTokens: 100,
+ totalTokens: 1_100,
+ }
+ const view = render()
+ const trigger = view.getByRole('button')
+ expect(trigger.textContent).toBe('22:08 · Turn usage 1.1K tok · Cache hit 99.9%')
+
+ fireEvent.click(trigger)
+ expect(view.getByRole('dialog')).toBeTruthy()
+ fireEvent.keyDown(document, { key: 'Escape' })
+ expect(view.queryByRole('dialog')).toBeNull()
+ expect(trigger.getAttribute('aria-expanded')).toBe('false')
+
+ fireEvent.click(trigger)
+ // A pointerdown inside the panel keeps it open; one outside closes it.
+ fireEvent.pointerDown(view.getByRole('dialog'))
+ expect(view.queryByRole('dialog')).toBeTruthy()
+ fireEvent.pointerDown(document.body)
+ expect(view.queryByRole('dialog')).toBeNull()
+ })
+})
diff --git a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
index a8c16c9383..f1050b6f2b 100644
--- a/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
+++ b/packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
@@ -15,11 +15,11 @@
cursor: pointer;
}
-/* The 16px glyphs follow the same px delta as the text they serve; the CSS
+/* The 15px glyphs follow the same px delta as the text they serve; the CSS
edge overrides each svg's own width/height attributes. */
.action svg {
- width: calc(16px + var(--dsh-content-font-delta, 0px));
- height: calc(16px + var(--dsh-content-font-delta, 0px));
+ width: calc(15px + var(--dsh-content-font-delta, 0px));
+ height: calc(15px + var(--dsh-content-font-delta, 0px));
}
.action:hover {
diff --git a/packages/client/ui-message-feedback/tests/styles.client.spec.ts b/packages/client/ui-message-feedback/tests/styles.client.spec.ts
index 83a049ab5d..2f2af6e341 100644
--- a/packages/client/ui-message-feedback/tests/styles.client.spec.ts
+++ b/packages/client/ui-message-feedback/tests/styles.client.spec.ts
@@ -97,7 +97,7 @@ describe('MessageFeedbackActions theme styles', () => {
// font size moves the row.
expect(block('.action')).toMatch(/width:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
expect(block('.action')).toMatch(/height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
- expect(block('.action svg')).toMatch(/width:\s*calc\(16px \+ var\(--dsh-content-font-delta, 0px\)\)/)
+ expect(block('.action svg')).toMatch(/width:\s*calc\(15px \+ var\(--dsh-content-font-delta, 0px\)\)/)
expect(block('.noteOpen')).toMatch(/line-height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
})
})
diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md
index 0c4683b73d..dcba687138 100644
--- a/snapshots/web/feedback-command/ack.expected.md
+++ b/snapshots/web/feedback-command/ack.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
- img
- img
diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md
index 53f8ac5c16..fb4fa71c14 100644
--- a/snapshots/web/message-actions/ui.expected.md
+++ b/snapshots/web/message-actions/ui.expected.md
@@ -31,7 +31,7 @@
- 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}} TTFT {{duration}} {{throughput}} tok/s
+- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}}
- button "Read a.txt":
- img
- img
diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md
index 66769e0488..ccacb6dd7b 100644
--- a/snapshots/web/queue-actions/preserved.expected.md
+++ b/snapshots/web/queue-actions/preserved.expected.md
@@ -29,7 +29,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
+- text: {{clock}} Ran for {{duration}}
- button "2 queued messages" [expanded]
- list:
- listitem:
diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md
index c049c42fe6..5fc1034229 100644
--- a/snapshots/web/seeded-history/command-row.expected.md
+++ b/snapshots/web/seeded-history/command-row.expected.md
@@ -47,7 +47,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: 7/25 {{clock}} Ran for {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md
index a24aaf78f0..d8a4bdf483 100644
--- a/snapshots/web/seeded-history/feedback-row.expected.md
+++ b/snapshots/web/seeded-history/feedback-row.expected.md
@@ -47,7 +47,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: 7/25 {{clock}} Ran for {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md
index d03f428f5d..b5819db45d 100644
--- a/snapshots/web/seeded-history/ui.expected.md
+++ b/snapshots/web/seeded-history/ui.expected.md
@@ -29,7 +29,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: 7/25 {{clock}} Ran for {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md
index 9e8037c6b3..027175c4d6 100644
--- a/snapshots/web/workflow-run/ui.expected.md
+++ b/snapshots/web/workflow-run/ui.expected.md
@@ -36,4 +36,4 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}}
From bc88e152c547d9aacd9a8753c91995a94d11ab9d Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Thu, 27 Aug 2026 18:35:09 +0800
Subject: [PATCH 2/8] test(ui-chat): restore turn-tail assertions lost in the
rebase merge
The rebase onto master's turn-process folding kept master's makeHarness
rework, which dropped the turnUsages passthrough and the single-trigger
footer assertions; re-merge both sides.
---
.../ui-chat/tests/chat-view.client.spec.tsx | 70 +++++++++++++++----
1 file changed, 58 insertions(+), 12 deletions(-)
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index 85a5d1f0c9..bf74fc890c 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -92,7 +92,9 @@ function makeSessionSource(init: Partial = {}) {
}
}
-type ChatSlice = Partial
+type ChatSlice = Partial & {
+ readonly turnUsages?: NonNullable[0]>['turnUsages']
+}
type HarnessUpdate = ChatSlice & Partial & { readonly chat?: ChatSnapshot }
/** Scripted Chat target source, independent from Session lifecycle state. */
@@ -205,7 +207,7 @@ function makeHarness(
chatSnapshot?: ChatSnapshot,
) {
const {
- chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds,
+ chat: initialChat, nodes, partial, runningCalls, turnTimings, turnEnds, turnUsages,
...sessionInit
} = init
const chatSlice: ChatSlice = {
@@ -214,6 +216,7 @@ function makeHarness(
...(runningCalls === undefined ? {} : { runningCalls }),
...(turnTimings === undefined ? {} : { turnTimings }),
...(turnEnds === undefined ? {} : { turnEnds }),
+ ...(turnUsages === undefined ? {} : { turnUsages }),
}
const session = makeSessionSource({ ...sessionInit, ...sessionOverrides })
const chatSource = makeChatSource(chatSlice, initialChat ?? chatSnapshot)
@@ -1536,7 +1539,7 @@ describe('ChatView', () => {
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
})
- it('the settled footer appends first-step ttft and turn decode throughput', () => {
+ it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => {
const first: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
@@ -1551,12 +1554,42 @@ describe('ChatView', () => {
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()
- // First-step ttft (1.2s) plus 100 tokens over 5s of decode.
- expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
- expect(view.getAllByText(/首 token 1\.2秒/)).toHaveLength(1)
- expect(view.getAllByText(/20 tok\/s/)).toHaveLength(1)
+ // First-step ttft (1.2s) plus 100 tokens over 5s of decode: the whole
+ // meta line (clock, run time, usage metrics) is one clickable trigger.
+ const trigger = view.getByRole('button', { name: /本轮用量 10\.1K tok/ })
+ expect(trigger.textContent).toMatch(
+ /^.+ · 用时 19秒 · 本轮用量 10\.1K tok · 缓存命中率 49\.4% · 速度 20 tok\/s · 首 token 1\.2秒$/,
+ )
+ expect(view.queryByRole('dialog')).toBeNull()
+ fireEvent.click(trigger)
+ const dialog = view.getByRole('dialog')
+ expect(dialog.getAttribute('aria-label')).toBe('本轮用量')
+ expect(dialog.textContent).toContain('未缓存输入5,060 tok')
+ expect(dialog.textContent).toContain('缓存命中率49.4%')
+ expect(dialog.textContent).toContain('总计10,100 tok')
+ })
+
+ it('withholds the usage-details trigger when turn usage is outside the window', () => {
+ const settled: AssistantMessageNode = {
+ kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],
+ timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
+ usage: { outputTokens: 40 },
+ }
+ const h = makeHarness({
+ nodes: [user(1, 'hi'), settled],
+ turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
+ turnEnds: new Map([[1, 20]]),
+ })
+ const view = render()
+ expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
})
it('withholds ttft and throughput while the turn is still running', () => {
@@ -1574,15 +1607,28 @@ describe('ChatView', () => {
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
})
- it('user and assistant message containers scope the hover-revealed time chrome', () => {
+ it('user rows scope the hover clock; turn tails gate the whole row by recency', () => {
const h = makeHarness({
- nodes: [user(1, 'hi'), assistant(2, 'answer')],
- turnTimings: new Map([[1, { startTime: 1_000, endTime: 2_000 }]]),
- turnEnds: new Map([[1, 2]]),
+ nodes: [
+ user(1, 'hi'),
+ assistant(2, 'answer'),
+ user(4, 'again'),
+ assistant(5, 'later answer', 2),
+ ],
+ turnTimings: new Map([
+ [1, { startTime: 1_000, endTime: 2_000 }],
+ [2, { startTime: 4_000, endTime: 5_000 }],
+ ]),
+ turnEnds: new Map([[1, 3], [2, 6]]),
})
const view = render()
- // The user row and the settled assistant's Turn Tail each own one clock scope.
+ // Only the user rows keep the time-hover scope; assistant tails moved to
+ // the recency-gated whole-row reveal.
expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
+ const tails = view.container.querySelectorAll('[data-actions-reveal]')
+ expect(new Map([...tails].map(tail => [
+ tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'),
+ ]))).toEqual(new Map([['1', 'hover'], ['2', 'always']]))
})
it('the run-time label is withheld when the turn start is outside the window', () => {
From f15078532fa8ad8540ccc9502b9372656a257d75 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Thu, 27 Aug 2026 23:14:08 +0800
Subject: [PATCH 3/8] feat(ui-chat): reshape the usage trigger as an icon-row
pill
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The whole-line meta trigger read as plain text and hid what was clickable.
The Turn-usage trigger is now a data-icon pill (Usage {total} · Cache hit
{percent}%) seated right of the branch action with the action buttons'
hover chrome, so the one interactive element in the tail is visibly a
button; the timing facts (clock, run time, speed, TTFT) return to plain
non-clickable text behind a dot separator. The details dialog keeps the
Turn-usage title and full token buckets, gains a permanent cache-hit row,
and breathes with wider vertical padding. Narrow columns trim the pill
label to an ellipsis instead of widening the chat column. User rows and
turn tails share the recency gate: only the latest row of each kind keeps
its actions visible without hover.
---
.../skill-user-invoke/ui-expanded.expected.md | 2 +-
.../expected/skill-user-invoke/ui.expected.md | 2 +-
.../steer-all/settled-expanded.expected.md | 2 +-
.../expected/steer-all/settled.expected.md | 2 +-
apps/web/tests/turn-tail-actions.e2e.ts | 9 +-
packages/client/ui-chat/package.json | 4 +-
.../client/chat/MessageIconActions.module.css | 47 ++++----
.../src/client/chat/MessageIconActions.tsx | 60 ++++++----
.../ui-chat/src/client/chat/MessageItem.tsx | 18 ++-
.../src/client/chat/TurnTailNodeView.tsx | 17 +--
.../src/client/chat/TurnUsagePanel.module.css | 75 ++++++++----
.../src/client/chat/TurnUsagePanel.tsx | 113 ++++++++++--------
packages/client/ui-chat/src/client/locale.ts | 26 +---
.../tests/chat-branch-tails.client.spec.tsx | 8 +-
.../chat-font-axis-styles.client.spec.ts | 38 +++---
.../ui-chat/tests/chat-view.client.spec.tsx | 31 +++--
.../tests/turn-usage-panel.client.spec.tsx | 36 +++---
.../ui-primitives/src/useAnchoredPosition.ts | 20 ++--
pnpm-lock.yaml | 6 +
snapshots/web/code-mode-round/ui.expected.md | 2 +-
.../feedback-command/ack-expanded.expected.md | 2 +-
.../web/feedback-command/ack.expected.md | 2 +-
.../feedback-release/ack-expanded.expected.md | 2 +-
.../web/feedback-release/ack.expected.md | 2 +-
.../fresh-round-trip/ui-expanded.expected.md | 2 +-
snapshots/web/fresh-round-trip/ui.expected.md | 2 +-
.../ui-expanded.expected.md | 4 +-
.../goal-multi-turn-actions/ui.expected.md | 4 +-
.../reloaded-expanded.expected.md | 2 +-
.../web/lifecycle-chrome/reloaded.expected.md | 2 +-
.../retry-expanded.expected.md | 2 +-
.../web/live-interactions/retry.expected.md | 2 +-
snapshots/web/message-actions/ui.expected.md | 2 +-
snapshots/web/minimal-preset/ui.expected.md | 2 +-
.../plan-review/approved-expanded.expected.md | 2 +-
.../web/plan-review/approved.expected.md | 2 +-
.../answered-expanded.expected.md | 2 +-
.../question-composer/answered.expected.md | 2 +-
.../web/queue-actions/preserved.expected.md | 2 +-
.../seeded-history/command-row.expected.md | 2 +-
.../seeded-history/feedback-row.expected.md | 2 +-
.../seeded-history/ui-expanded.expected.md | 2 +-
snapshots/web/seeded-history/ui.expected.md | 2 +-
snapshots/web/skill-tool-row/ui.expected.md | 2 +-
.../web/steering/settled-expanded.expected.md | 2 +-
snapshots/web/steering/settled.expected.md | 2 +-
.../ui-expanded.expected.md | 4 +-
.../web/subagent-conversation/ui.expected.md | 4 +-
.../turn-tail-actions/completed.expected.md | 9 +-
.../web/turn-tail-actions/focused.expected.md | 9 +-
.../web/turn-tail-actions/settled.expected.md | 2 +-
.../usage-expanded.expected.md | 18 +--
snapshots/web/web-search-round/ui.expected.md | 2 +-
snapshots/web/workflow-run/ui.expected.md | 2 +-
54 files changed, 344 insertions(+), 280 deletions(-)
diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
index 8a1e8287a7..f06169c311 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
index dea9369875..3281b27094 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
index 39c0dc68da..c220ca0d5d 100644
--- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md
+++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
@@ -46,7 +46,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md
index f17b2e14c7..7a9146a5b5 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -34,7 +34,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index db2840a29e..99b2572d60 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/apps/web/tests/turn-tail-actions.e2e.ts
@@ -170,10 +170,13 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const { settled } = await sendPrompt(120_000)
await settled
- const trigger = page.getByRole('button', { name: /Cache hit 49\.7%/ })
+ 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')
- expect(await trigger.textContent()).toMatch(/^.+ · Ran for .+ · Turn usage 15\.8K tok · Cache hit 49\.7% · \d+ tok\/s · TTFT \S+$/)
+ // 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)
expect(await page.getByRole('dialog').count()).toBe(0)
await trigger.click()
@@ -181,10 +184,10 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const dialog = page.getByRole('dialog', { name: 'Turn usage' })
expect(await dialog.count()).toBe(1)
expect(await dialog.getByText('deepseek-official/deepseek-v4-flash', { exact: true }).count()).toBe(1)
+ expect(await dialog.getByText('49.7%', { exact: true }).count()).toBe(1)
expect(await dialog.getByText('7,891 tok', { exact: true }).count()).toBe(1)
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('49.7%', { exact: true }).count()).toBe(1)
expect(await dialog.getByText('15,811 tok', { exact: true }).count()).toBe(1)
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json
index 10382a63bf..945f4cfeb5 100644
--- a/packages/client/ui-chat/package.json
+++ b/packages/client/ui-chat/package.json
@@ -107,7 +107,9 @@
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-util-workspace-path": "workspace:^",
"@types/react": "~18.3.1",
- "react": "^18.2.0"
+ "@types/react-dom": "~18.3.0",
+ "react": "^18.2.0",
+ "react-dom": "^18.2.0"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
index 5e31ea2e2f..37b48b77dc 100644
--- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
+++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
@@ -1,6 +1,6 @@
/* Shared message IconActions row (user + assistant). Parent modules own
- layout offsets via the composed className. Icons stay visible when mounted;
- the time label is hover-revealed inside a data-time-hover-root scope. */
+ layout offsets via the composed className. Row containers gate visibility
+ by recency through data-actions-reveal. */
.actions {
display: flex;
@@ -9,10 +9,12 @@
height: calc(28px + var(--dsh-content-font-delta, 0px));
}
-/* Clock before icons (user figma 388:20051) / after (assistant 43:32997). */
+/* Clock before icons (user figma 388:20051) / after (assistant 43:32997).
+ Both read the secondary tier so the user row's clock matches the assistant
+ tail's meta line. */
.timeStart {
padding-right: 12px;
- font-size: var(--dsh-content-font-size, 14px);
+ 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;
@@ -33,24 +35,27 @@
margin: 0 5px;
}
-/* 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. */
+/* 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
+ latest ('always') keeps its row visible — no rule matches it. Opacity keeps
+ the layout stable, and devices without hover keep the row 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;
- }
-
- /* Turn tails opt in with data-actions-reveal='hover' instead: earlier turns
- reveal the entire row (icons and text) on hover or focus, while the latest
- turn ('always') keeps its row visible. Same opacity gate as the time
- labels, so layout never moves and no-hover devices keep the row visible. */
[data-actions-reveal='hover'] .actions {
opacity: 0;
transition: opacity 80ms ease;
diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
index 8735850b30..b53ff20ae6 100644
--- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
+++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
@@ -1,12 +1,14 @@
// Shared IconActions chrome for user and assistant messages: copy
// live, optional branch wiring, and an optional date-aware clock.
-import { useCallback, useEffect, useId, useRef, useState, type ReactNode } from 'react'
+import { Fragment, 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 { formatMessageClock, formatRunDuration } from './message-chrome.ts'
+import {
+ formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond,
+} from './message-chrome.ts'
import { useCalendarDay } from './use-calendar-day.ts'
import css from './MessageIconActions.module.css'
@@ -17,6 +19,10 @@ export interface MessageIconActionsProps {
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. */
@@ -31,11 +37,10 @@ export interface MessageIconActionsProps {
*/
extraActions?: ReactNode
/**
- * Whole-line meta chrome (the TurnUsagePanel trigger) rendered in the `end`
- * clock's seat; when present the caller omits `time`/`runMs` so the trigger
- * owns the entire line.
+ * Icon-row Turn-usage trigger (the TurnUsagePanel pill), seated after the
+ * branch control at the end of the icon cluster.
*/
- usageDetails?: ReactNode
+ usageAction?: ReactNode
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -46,8 +51,8 @@ export interface MessageIconActionsProps {
* @returns The actions row element.
*/
export function MessageIconActions({
- text, time, runMs, clock, onBranch, branchUnavailable = false, className,
- extraActions, usageDetails, t,
+ text, time, runMs, tokensPerSecond, ttftMs, clock, onBranch, branchUnavailable = false, className,
+ extraActions, usageAction, t,
}: MessageIconActionsProps) {
const day = useCalendarDay()
const reasonId = useId()
@@ -77,20 +82,30 @@ export function MessageIconActions({
}, 1000)
})
}, [copied, text])
- // The dot is decorative and stays hidden, but its margins separate the
+ // 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 two facts.
- const clockEl = time === undefined ? null : (
+ // 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 : (
- {formatMessageClock(time, t, day)}
- {runMs !== undefined && (
- <>
- {' '}
- ·
- {' '}
- {t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
- >
- )}
+ {meta.map((segment, index) => (
+
+ {index > 0 && (
+ <>
+ {' '}
+ ·
+ {' '}
+ >
+ )}
+ {segment}
+
+ ))}
)
return (
@@ -121,8 +136,11 @@ export function MessageIconActions({
{onBranch !== undefined && branchUnavailable && (
{t('message.branchUnavailable')}
)}
+ {usageAction}
+ {usageAction != null && clock === 'end' && clockEl !== null && (
+ ·
+ )}
{clock === 'end' ? clockEl : null}
- {clock === 'end' ? usageDetails : null}
)
}
diff --git a/packages/client/ui-chat/src/client/chat/MessageItem.tsx b/packages/client/ui-chat/src/client/chat/MessageItem.tsx
index 467e486ecb..cb8084fe41 100644
--- a/packages/client/ui-chat/src/client/chat/MessageItem.tsx
+++ b/packages/client/ui-chat/src/client/chat/MessageItem.tsx
@@ -148,7 +148,7 @@ function TurnMaxTokensItem({ t }: {
/** Right-aligned bubble shared by user and steering rows. */
function UserStyleBubble({
- content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, t,
+ content, renderMessageImages, actions, pending = false, echo = false, referenceLabels = [], previewImages, reveal = 'always', t,
}: {
content: readonly unknown[]
renderMessageImages: ChatNodeOwnerProps['renderMessageImages']
@@ -162,6 +162,8 @@ function UserStyleBubble({
referenceLabels?: readonly string[]
/** Local submission-echo previews replacing the content-derived image group. */
previewImages?: readonly MessageImageSource[]
+ /** Whole actions-row visibility: earlier rows reveal on hover, the latest stays shown (turn tails' gate). */
+ reveal?: 'always' | 'hover'
t: ChatViewSlotProps['t']
}): ReactNode {
const { text, images: contentImages, rest } = contentParts(content)
@@ -173,7 +175,7 @@ function UserStyleBubble({
className={css.userRow}
data-pending-steering={pending || undefined}
data-submission-echo={echo || undefined}
- data-time-hover-root
+ data-actions-reveal={reveal}
>
{renderMessageImages({ images, align: 'end' })}
@@ -271,14 +273,24 @@ export function PendingSubmissionBubble({ submission, renderMessageImages, t }:
/** User and admitted-steering keyed Chat renderer. */
export const UserMessageNodeView = memo(function UserMessageNodeView({
- node, renderMessageImages, t,
+ node, renderMessageImages, useChat, t,
}: ChatNodeViewProps<'user' | 'steering'>) {
const data = node.data
+ // The transcript's last user-authored row keeps its actions row shown, the
+ // same recency gate turn tails use; earlier rows reveal on hover.
+ const isLatestUserRow = useChat((snapshot) => {
+ for (let index = snapshot.order.length - 1; index >= 0; index -= 1) {
+ const candidate = snapshot.nodes.get(snapshot.order[index] ?? '')
+ if (candidate?.kind === 'user' || candidate?.kind === 'steering') return candidate.key === node.key
+ }
+ return true
+ })
return (
(
{ forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
- usageDetails={data.tokenUsage === undefined ? null : (
-
+ usageAction={data.tokenUsage === undefined ? null : (
+
)}
t={t}
/>
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
index 1c6ac61142..f95534d7c0 100644
--- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
+++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
@@ -1,56 +1,68 @@
-/* Whole-line meta trigger on the finalized footer row, plus its click-open
+/* Icon-row Turn-usage pill (data icon + turn total) plus its click-open
Turn-details dialog (menu surface: r12, inverted hairline, shadow-lv3 —
ContextMeter's panel skin). */
.root {
- position: relative;
display: inline-flex;
min-width: 0;
- /* Same 12px inset the plain clock label (.timeEnd) keeps from the icons. */
- margin-left: 12px;
}
-/* Same text treatment as the row's clock label (secondary tier, one step
- under the body size); hover brightens one step to signal the cluster is
- clickable. min-width 0 + hidden overflow let the one-line cluster shrink
- with the chat column and ellipsize instead of widening it. */
+/* 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. */
.trigger {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
min-width: 0;
- padding: 0;
+ height: calc(28px + var(--dsh-content-font-delta, 0px));
+ padding: 6px 8px;
border: none;
+ border-radius: 28px;
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;
+ cursor: pointer;
+}
+
+/* A narrow column trims the label to an ellipsis instead of overflowing the
+ pill; the data glyph never shrinks. */
+.label {
+ min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
- cursor: pointer;
+}
+
+.trigger svg {
+ width: calc(15px + var(--dsh-content-font-delta, 0px));
+ height: calc(15px + var(--dsh-content-font-delta, 0px));
+ flex: none;
}
.trigger:hover,
.trigger[aria-expanded='true'] {
+ background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
-/* Same segment separators as the plain clock row's runTimeDot, so the
- clickable and plain forms of the meta line space identically. */
-.dot {
- margin: 0 5px;
-}
-
+/* 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
+ layer above modal overlays (z 1000). */
.panel {
- position: absolute;
- bottom: calc(100% + 8px);
- left: 0;
- z-index: 100;
+ position: fixed;
+ z-index: 1100;
box-sizing: border-box;
- /* Size to the widest row so provider/model stays on one line, within a cap. */
+ /* Size to the widest row so provider/model stays on one line, within a cap.
+ Both bounds yield to a viewport narrower than themselves (12px margins),
+ so a fixed floor cannot push the panel past the placement clamp. */
width: max-content;
- min-width: 300px;
+ min-width: min(300px, calc(100vw - 24px));
max-width: min(440px, calc(100vw - 24px));
- padding: 12px;
+ padding: 16px;
border: 1px solid var(--dsw-alias-border-inverted);
border-radius: 12px;
background: var(--dsw-specific-menu);
@@ -61,6 +73,13 @@
cursor: default;
}
+/* Dialog heading, same size and weight as the total row it concludes with. */
+.title {
+ margin-bottom: 12px;
+ color: var(--dsw-alias-label-primary);
+ font-weight: 500;
+}
+
.details {
display: grid;
grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
@@ -90,10 +109,16 @@
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 {
- padding-top: 8px;
- border-top: 1px solid var(--dsw-alias-border-l2);
color: var(--dsw-alias-label-primary);
font-weight: 500;
}
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
index 65709cebb8..79cbb6617b 100644
--- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
@@ -1,27 +1,17 @@
-// Single-row Turn chrome: the finalized footer's whole meta line (clock,
-// duration, turn total, throughput, TTFT, cache-hit) as one inline trigger
-// whose click opens the per-Turn details dialog.
+// 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.
-import { Fragment, useEffect, useRef, useState } from 'react'
+import { useEffect, useRef, useState, type CSSProperties } from 'react'
+import { createPortal } from 'react-dom'
+import { IconDataOutline16, 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
- /** Turn-close instant (unix epoch ms) shown as the line's leading clock. */
- time: number
- /** Turn elapsed run time in ms, shown as `用时 6秒`; omitted when unrecorded. */
- runMs?: number | undefined
- /** Turn first-step TTFT in ms, shown as `TTFT 1.2s`; omitted when unrecorded. */
- ttftMs?: number | undefined
- /** Turn decode throughput, shown as `34 tok/s`; omitted when unrecorded. */
- tokensPerSecond?: number | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
@@ -34,21 +24,46 @@ function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
}
+/** Viewport margin the placement clamp keeps (the Menu portal margin). */
+const PANEL_MARGIN = 12
+
+/** Distance between the trigger's top edge and the panel's bottom. */
+const PANEL_GAP = 8
+
/**
- * Whole-line clickable meta cluster with a click-open Turn-details dialog.
- * @param props - Turn usage buckets, close time, optional timing metrics, locale seat.
- * @returns The trigger and, while open, its anchored dialog.
+ * Unplaced portal panel: hidden but laid out so the clamp measures real
+ * dimensions (the `useAnchoredPosition` measure pass).
*/
-export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t }: TurnUsagePanelProps) {
+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) {
const [open, setOpen] = useState(false)
const rootRef = useRef(null)
- const day = useCalendarDay()
+ const panelRef = useRef(null)
+
+ // Portal placement: the dialog is fixed above the trigger and clamped inside
+ // the viewport, so a trigger near the window edge cannot push it off-screen.
+ const pos = useAnchoredPosition({
+ open,
+ anchorRef: rootRef,
+ panelRef,
+ side: 'top',
+ gap: PANEL_GAP,
+ margin: PANEL_MARGIN,
+ })
// Outside click / Escape close, one document listener while open (ContextMeter's pattern).
useEffect(() => {
if (!open) return
const onPointerDown = (e: PointerEvent): void => {
- if (e.target instanceof Node && rootRef.current?.contains(e.target) === true) return
+ if (!(e.target instanceof Node)) return
+ if (rootRef.current?.contains(e.target) === true) return
+ if (panelRef.current?.contains(e.target) === true) return
setOpen(false)
}
const onKeyDown = (e: KeyboardEvent): void => {
@@ -67,15 +82,6 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
: 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 clock = formatMessageClock(time, t, day)
- const metrics: string[] = [clock]
- if (runMs !== undefined) metrics.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) }))
- metrics.push(t('message.turnUsage.consumed', { total }))
- if (cacheHit !== null) metrics.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit }))
- if (tokensPerSecond !== undefined) {
- metrics.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) }))
- }
- if (ttftMs !== undefined) metrics.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) }))
return (
@@ -86,21 +92,22 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
- {metrics.map((metric, index) => (
-
- {index > 0 && (
- <>
- {' '}
- ·
- {' '}
- >
- )}
- {metric}
-
- ))}
+
+
+ {cacheHit === null
+ ? t('message.turnUsage.consumed', { total })
+ : `${t('message.turnUsage.consumed', { total })} · ${t('message.turnUsage.cacheHitRate', { percent: cacheHit })}`}
+
- {open && (
-
+ {open && createPortal(
+
+
{t('message.turnUsage.title')}
{routes !== '' && (
<>
@@ -108,6 +115,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
- {routes}
>
)}
+ {cacheHit !== null && (
+ <>
+ - {t('message.turnUsage.cacheHit')}
+ - {`${cacheHit}%`}
+ >
+ )}
- {t('message.turnUsage.input')}
- {formatExactCount(usage.uncachedInputTokens, t)}
{usage.cacheReadTokens !== undefined && (
@@ -131,16 +144,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
)}
- {cacheHit !== null && (
- <>
- - {t('message.turnDetails.cacheHit')}
- - {`${cacheHit}%`}
- >
- )}
+
- {t('message.turnUsage.total')}
- {formatExactCount(usage.totalTokens, t)}
-
+
,
+ document.body,
)}
)
diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts
index 2caec97796..cb34f59c7c 100644
--- a/packages/client/ui-chat/src/client/locale.ts
+++ b/packages/client/ui-chat/src/client/locale.ts
@@ -91,18 +91,11 @@ export const zh = {
'message.ttft': '首 token {seconds}秒',
'message.tokensPerSecond': '{tps} tok/s',
'message.turnUsage.title': '本轮用量',
- 'message.turnUsage.summaryWithCache': '{total} · 缓存命中率 {percent}%',
- 'message.turnUsage.consumed': '本轮用量 {total}',
+ 'message.turnUsage.consumed': '用量 {total}',
+ 'message.turnUsage.cacheHitRate': '缓存命中 {percent}%',
'message.turnUsage.speed': '速度 {tps} tok/s',
- 'message.turnUsage.cacheHitRate': '缓存命中率 {percent}%',
- 'message.turnDetails.title': '本轮详情',
- 'message.turnDetails.time': '时间',
- 'message.turnDetails.duration': '用时',
- 'message.turnDetails.ttft': '首 token',
- 'message.turnDetails.speed': '速度',
- 'message.turnDetails.usage': '用量',
- 'message.turnDetails.cacheHit': '缓存命中率',
'message.turnUsage.model': '提供方 / 模型',
+ 'message.turnUsage.cacheHit': '缓存命中',
'message.turnUsage.input': '未缓存输入',
'message.turnUsage.cacheRead': '缓存读取',
'message.turnUsage.cacheWrite': '缓存写入',
@@ -214,18 +207,11 @@ export const en = {
'message.ttft': 'TTFT {seconds}s',
'message.tokensPerSecond': '{tps} tok/s',
'message.turnUsage.title': 'Turn usage',
- 'message.turnUsage.summaryWithCache': '{total} · Cache hit {percent}%',
- 'message.turnUsage.consumed': 'Turn usage {total}',
- 'message.turnUsage.speed': '{tps} tok/s',
+ 'message.turnUsage.consumed': 'Usage {total}',
'message.turnUsage.cacheHitRate': 'Cache hit {percent}%',
- 'message.turnDetails.title': 'Turn details',
- 'message.turnDetails.time': 'Time',
- 'message.turnDetails.duration': 'Duration',
- 'message.turnDetails.ttft': 'TTFT',
- 'message.turnDetails.speed': 'Speed',
- 'message.turnDetails.usage': 'Usage',
- 'message.turnDetails.cacheHit': 'Cache hit',
+ 'message.turnUsage.speed': '{tps} tok/s',
'message.turnUsage.model': 'Provider / model',
+ 'message.turnUsage.cacheHit': 'Cache hit',
'message.turnUsage.input': 'Uncached input',
'message.turnUsage.cacheRead': 'Cached input',
'message.turnUsage.cacheWrite': 'Cache write',
diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
index 985a452fd6..1c795904b9 100644
--- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
@@ -39,6 +39,12 @@ const t: ChatNodeViewProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
const RETRY_ID = 'retry-fixture' as Extract['retryId']
+// Recency scans the whole transcript; a detached fixture is its own latest row.
+const useDetachedChat: ChatNodeViewProps['useChat'] = bindSnapshotSelector({
+ subscribe: () => () => {},
+ getSnapshot: () => ({ order: [], nodes: new Map() }),
+} as never)
+
interface MessageItemProps {
readonly node: ConversationNode
readonly t: ChatNodeViewProps['t']
@@ -62,7 +68,7 @@ function MessageItem({ node, t: translate, referenceLabels }: MessageItemProps)
? { ...node, referenceLabels }
: node,
}
- const props = { node: viewNode, t: translate, renderMessageImages } as ChatNodeViewProps
+ const props = { node: viewNode, t: translate, renderMessageImages, useChat: useDetachedChat } as ChatNodeViewProps
switch (node.kind) {
case 'user':
case 'steering':
diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
index b11c53e87b..a279c63c4d 100644
--- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
+++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
@@ -13,7 +13,7 @@ const read = (name: string): string =>
function declarationsFrom(source: string, selector: string): string[] {
const declarationText = source.replace(/\/\*[\s\S]*?\*\//g, ' ')
- const rule = new RegExp(`(?:^|\\})\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText)
+ const rule = new RegExp(`(?:^|[{}])\\s*${selector.replace(/[.[\]():*+^$\\]/g, '\\$&')}\\s*\\{([^{}]*)\\}`).exec(declarationText)
if (rule === null) throw new Error(`no \`${selector}\` rule`)
return (rule[1] ?? '').split(';').map(part => part.trim()).filter(Boolean)
}
@@ -45,11 +45,12 @@ describe('chat flow font-size axis', () => {
it('the message clock and action glyphs scale with the text they serve', () => {
const actions = read('MessageIconActions.module.css')
+ // Both clocks read the secondary tier: the assistant tail's meta line
+ // (the whole-line usage trigger) and the user row's clock stay one step
+ // under the body size so the two rows match.
expect(declarationsFrom(actions, '.timeStart')).toEqual(expect.arrayContaining([
- 'font-size: var(--dsh-content-font-size, 14px)',
+ 'font-size: var(--dsh-content-font-size-secondary, 13px)',
]))
- // The assistant tail's meta line (the whole-line usage trigger) sits one
- // step under the body size; the user row's clock keeps the body size.
expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining([
'font-size: var(--dsh-content-font-size-secondary, 13px)',
]))
@@ -93,26 +94,27 @@ describe('chat flow font-size axis', () => {
]))
})
- it('the whole-line trigger ellipsizes instead of widening the chat column', () => {
- // The one-line meta cluster stays nowrap; min-width 0 lets the flex item
- // shrink with the column and the hidden overflow trims to an ellipsis.
+ it('the usage pill sizes its glyph and hit height like the sibling action buttons', () => {
+ // The pill sits in the icon row right of the branch action; its data glyph
+ // and 28px hit height follow the same delta rule as `.action` so the row
+ // stays one height at every font size.
const css = read('TurnUsagePanel.module.css')
expect(declarationsFrom(css, '.trigger')).toEqual(expect.arrayContaining([
- 'min-width: 0',
+ 'height: calc(28px + var(--dsh-content-font-delta, 0px))',
'white-space: nowrap',
+ 'min-width: 0',
+ ]))
+ expect(declarationsFrom(css, '.trigger svg')).toEqual(expect.arrayContaining([
+ 'width: calc(15px + var(--dsh-content-font-delta, 0px))',
+ 'height: calc(15px + var(--dsh-content-font-delta, 0px))',
+ ]))
+ // A narrow column trims the pill label to an ellipsis instead of letting
+ // it overflow or widen the chat column.
+ expect(declarationsFrom(css, '.label')).toEqual(expect.arrayContaining([
+ 'min-width: 0',
'overflow: hidden',
'text-overflow: ellipsis',
]))
- expect(declarationsFrom(css, '.root')).toEqual(expect.arrayContaining(['min-width: 0']))
- })
-
- it('the clickable and plain meta lines keep identical inset and separator spacing', () => {
- const panel = read('TurnUsagePanel.module.css')
- const actions = read('MessageIconActions.module.css')
- expect(declarationsFrom(panel, '.dot')).toEqual(['margin: 0 5px'])
- expect(declarationsFrom(actions, '.runTimeDot')).toEqual(['margin: 0 5px'])
- expect(declarationsFrom(panel, '.root')).toEqual(expect.arrayContaining(['margin-left: 12px']))
- expect(declarationsFrom(actions, '.timeEnd')).toEqual(expect.arrayContaining(['padding-left: 12px']))
})
it('non-latest turn tails hide the whole actions row until hover or focus', () => {
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index bf74fc890c..ef60154e61 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -1562,18 +1562,18 @@ describe('ChatView', () => {
}]]),
})
const view = render()
- // First-step ttft (1.2s) plus 100 tokens over 5s of decode: the whole
- // meta line (clock, run time, usage metrics) is one clickable trigger.
- const trigger = view.getByRole('button', { name: /本轮用量 10\.1K tok/ })
- expect(trigger.textContent).toMatch(
- /^.+ · 用时 19秒 · 本轮用量 10\.1K tok · 缓存命中率 49\.4% · 速度 20 tok\/s · 首 token 1\.2秒$/,
- )
+ // 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.
+ 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(view.queryByRole('dialog')).toBeNull()
fireEvent.click(trigger)
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('本轮用量')
+ expect(dialog.textContent).toContain('缓存命中49.4%')
expect(dialog.textContent).toContain('未缓存输入5,060 tok')
- expect(dialog.textContent).toContain('缓存命中率49.4%')
expect(dialog.textContent).toContain('总计10,100 tok')
})
@@ -1589,7 +1589,10 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 20]]),
})
const view = render()
- expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
+ // 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()
+ expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
})
it('withholds ttft and throughput while the turn is still running', () => {
@@ -1607,7 +1610,7 @@ describe('ChatView', () => {
expect(view.queryByText(/首 token|tok\/s/)).toBeNull()
})
- it('user rows scope the hover clock; turn tails gate the whole row by recency', () => {
+ it('user rows and turn tails both gate the whole actions row by recency', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
@@ -1622,13 +1625,15 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 3], [2, 6]]),
})
const view = render()
- // Only the user rows keep the time-hover scope; assistant tails moved to
- // the recency-gated whole-row reveal.
- expect(view.container.querySelectorAll('[data-time-hover-root]')).toHaveLength(2)
- const tails = view.container.querySelectorAll('[data-actions-reveal]')
+ // The last user-authored row and the latest turn's tail stay shown;
+ // every earlier row of either kind reveals on hover.
+ const tails = view.container.querySelectorAll('[data-turn-tail]')
expect(new Map([...tails].map(tail => [
tail.getAttribute('data-turn-tail'), tail.getAttribute('data-actions-reveal'),
]))).toEqual(new Map([['1', 'hover'], ['2', 'always']]))
+ const userRows = [...view.container.querySelectorAll('[data-actions-reveal]')]
+ .filter(row => row.getAttribute('data-turn-tail') === null)
+ expect(userRows.map(row => row.getAttribute('data-actions-reveal'))).toEqual(['hover', 'always'])
})
it('the run-time label is withheld when the turn start is outside the window', () => {
diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
index d99fc0598f..e97b88af10 100644
--- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
+++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
@@ -10,13 +10,10 @@ import { en } from '../src/client/locale.ts'
const t = makeTranslate(en, commonEn)
-// Today at 22:08 local, so formatMessageClock renders the bare `HH:mm` form.
-const TIME = new Date().setHours(22, 8, 0, 0)
-
afterEach(cleanup)
describe('TurnUsagePanel', () => {
- it('exposes the whole meta line inline and opens the usage dialog on click', () => {
+ it('shows an icon-and-total pill and opens the usage dialog on click', () => {
const usage: TurnTokenUsage = {
uncachedInputTokens: 5_060,
cacheReadTokens: 4_940,
@@ -26,12 +23,11 @@ describe('TurnUsagePanel', () => {
totalTokens: 15_800,
routes: [{ provider: 'deepseek', model: 'deepseek-chat' }],
}
- const view = render(
- ,
- )
+ const view = render()
const trigger = view.getByRole('button')
- expect(trigger.textContent).toBe('22:08 · Ran for 13s · Turn usage 15.8K tok · Cache hit 49.4% · 107 tok/s · TTFT 2s')
+ expect(trigger.textContent).toBe('Usage 15.8K tok · Cache hit 49.4%')
+ expect(trigger.querySelector('svg')).not.toBeNull()
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
expect(trigger.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByRole('dialog')).toBeNull()
@@ -40,14 +36,17 @@ 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.
+ expect(dialog.parentElement).toBe(document.body)
+ expect(dialog.firstChild?.textContent).toBe('Turn usage')
const details = dialog.querySelector('[data-turn-usage-details]') as HTMLElement
expect(details).toBeTruthy()
expect(details.textContent).toContain('Provider / modeldeepseek/deepseek-chat')
+ expect(details.textContent).toContain('Cache hit49.4%')
expect(details.textContent).toContain('Uncached input5,060 tok')
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('Cache hit49.4%')
expect(details.textContent).toContain('Total15,800 tok')
})
@@ -57,33 +56,34 @@ describe('TurnUsagePanel', () => {
outputTokens: 30,
totalTokens: 150,
}
- const view = render()
+ const view = render()
const trigger = view.getByRole('button')
- expect(trigger.textContent).toBe('22:08 · Turn usage 150 tok')
- expect(view.queryByText(/Cache hit/)).toBeNull()
- expect(view.queryByText(/TTFT/)).toBeNull()
- expect(view.queryByText(/tok\/s/)).toBeNull()
+ expect(trigger.textContent).toBe('Usage 150 tok')
fireEvent.click(trigger)
expect(view.queryByText('Provider / model')).toBeNull()
+ expect(view.queryByText('Cache hit')).toBeNull()
expect(view.queryByText('Cached input')).toBeNull()
expect(view.queryByText('Cache write')).toBeNull()
expect(view.queryByText(/reasoning/)).toBeNull()
})
- it('keeps a partial cache hit below 100 and closes on Escape or outside pointerdown', () => {
+ it('keeps a partial cache hit below 100 in the dialog and closes on Escape or outside pointerdown', () => {
const usage: TurnTokenUsage = {
uncachedInputTokens: 1,
cacheReadTokens: 999,
outputTokens: 100,
totalTokens: 1_100,
}
- const view = render()
+ const view = render()
const trigger = view.getByRole('button')
- expect(trigger.textContent).toBe('22:08 · Turn usage 1.1K tok · Cache hit 99.9%')
+ // The pill carries the compact total and cache-hit rate; exact token
+ // counts stay in the dialog.
+ expect(trigger.textContent).toBe('Usage 1.1K tok · Cache hit 99.9%')
fireEvent.click(trigger)
- expect(view.getByRole('dialog')).toBeTruthy()
+ const dialog = view.getByRole('dialog')
+ expect(dialog.textContent).toContain('Cache hit99.9%')
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.queryByRole('dialog')).toBeNull()
expect(trigger.getAttribute('aria-expanded')).toBe('false')
diff --git a/packages/client/ui-primitives/src/useAnchoredPosition.ts b/packages/client/ui-primitives/src/useAnchoredPosition.ts
index 07feb71d2f..90404bd639 100644
--- a/packages/client/ui-primitives/src/useAnchoredPosition.ts
+++ b/packages/client/ui-primitives/src/useAnchoredPosition.ts
@@ -3,10 +3,10 @@
*
* A portaled panel is positioned from its anchor's viewport rect, which stops
* being true the moment anything scrolls or the window resizes. This owns that
- * one concern: measure the anchor, offset the panel below it, clamp the result
- * inside the viewport, and re-run on scroll (capture phase, so scrollers nested
- * inside the page are caught too), on resize, and on the panel's own size
- * changes while the element is open.
+ * one concern: measure the anchor, offset the panel below or above it, clamp
+ * the result inside the viewport, and re-run on scroll (capture phase, so
+ * scrollers nested inside the page are caught too), on resize, and on the
+ * panel's own size changes while the element is open.
* @module @deepseek-ai/dsh-client-ui-primitives/useAnchoredPosition
*/
@@ -20,7 +20,9 @@ export interface AnchoredPositionOptions {
anchorRef: RefObject
/** The floating element, measured so the clamp uses real dimensions. */
panelRef: RefObject
- /** Distance kept between the anchor's bottom edge and the panel's top. */
+ /** Which anchor edge the panel hangs from: below it (`bottom`, the default) or above it (`top`). */
+ side?: 'top' | 'bottom'
+ /** Distance kept between the anchor edge named by `side` and the panel. */
gap: number
/** Distance kept between the panel and each viewport edge. */
margin: number
@@ -28,11 +30,11 @@ export interface AnchoredPositionOptions {
/**
* Track an anchor and return the panel's fixed coordinates.
- * @param options - the open state, the two refs, and the gap/margin distances.
+ * @param options - the open state, the two refs, the placement side, and the gap/margin distances.
* @returns `left`/`top` for the panel, or `null` before the first measurement.
*/
export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProperties | null {
- const { open, anchorRef, panelRef, gap, margin } = options
+ const { open, anchorRef, panelRef, side = 'bottom', gap, margin } = options
const [position, setPosition] = useState(null)
useLayoutEffect(() => {
if (!open) {
@@ -49,7 +51,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
const width = panel?.offsetWidth ?? 0
const height = panel?.offsetHeight ?? 0
let left = rect.left
- let top = rect.bottom + gap
+ let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
if (height > 0) top = Math.min(Math.max(top, margin), window.innerHeight - height - margin)
/* v8 ignore stop */
@@ -76,6 +78,6 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
- }, [open, anchorRef, panelRef, gap, margin])
+ }, [open, anchorRef, panelRef, side, gap, margin])
return position
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f7aec63d5d..0cd0255ae3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2053,9 +2053,15 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
+ '@types/react-dom':
+ specifier: ~18.3.0
+ version: 18.3.7(@types/react@18.3.31)
react:
specifier: ^18.2.0
version: 18.3.1
+ react-dom:
+ specifier: ^18.2.0
+ version: 18.3.1(react@18.3.1)
packages/client/ui-commands:
dependencies:
diff --git a/snapshots/web/code-mode-round/ui.expected.md b/snapshots/web/code-mode-round/ui.expected.md
index 32f481d4ff..7d470fc968 100644
--- a/snapshots/web/code-mode-round/ui.expected.md
+++ b/snapshots/web/code-mode-round/ui.expected.md
@@ -49,7 +49,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md
index 72988cae07..6a97b82d8b 100644
--- a/snapshots/web/feedback-command/ack-expanded.expected.md
+++ b/snapshots/web/feedback-command/ack-expanded.expected.md
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
- img
- img
diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md
index dcba687138..11f347f1d1 100644
--- a/snapshots/web/feedback-command/ack.expected.md
+++ b/snapshots/web/feedback-command/ack.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}}
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."':
- img
- img
diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md
index 0c5ebce2fb..c992c0db80 100644
--- a/snapshots/web/feedback-release/ack-expanded.expected.md
+++ b/snapshots/web/feedback-release/ack-expanded.expected.md
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- '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
diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md
index 1918a0e3ca..b6c5cf91bc 100644
--- a/snapshots/web/feedback-release/ack.expected.md
+++ b/snapshots/web/feedback-release/ack.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- '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
diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md
index 92222fbcd8..1b72b70fe2 100644
--- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md
+++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md
@@ -44,7 +44,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md
index 9085361adc..49dc055318 100644
--- a/snapshots/web/fresh-round-trip/ui.expected.md
+++ b/snapshots/web/fresh-round-trip/ui.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
index d943f181c2..79e0af28fb 100644
--- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
+++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
@@ -97,7 +97,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "System prompt":
- img
- img
@@ -217,7 +217,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Back to bottom":
- img
- textbox "Message or run a task... / commands, @ files or sessions"
diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md
index 8b0b4737dc..32ac5cc62e 100644
--- a/snapshots/web/goal-multi-turn-actions/ui.expected.md
+++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md
@@ -42,7 +42,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "System prompt":
- img
- img
@@ -107,7 +107,7 @@
- button "Branch into a new conversation":
- img
- tooltip "Branch into a new conversation"
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
index 31ff786841..9a45f772a8 100644
--- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
+++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md
index 8031b8de59..358fb187cc 100644
--- a/snapshots/web/lifecycle-chrome/reloaded.expected.md
+++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md
index 04c061962c..c341b22062 100644
--- a/snapshots/web/live-interactions/retry-expanded.expected.md
+++ b/snapshots/web/live-interactions/retry-expanded.expected.md
@@ -38,7 +38,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md
index 850d151cb9..695af671e3 100644
--- a/snapshots/web/live-interactions/retry.expected.md
+++ b/snapshots/web/live-interactions/retry.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md
index fb4fa71c14..826509803f 100644
--- a/snapshots/web/message-actions/ui.expected.md
+++ b/snapshots/web/message-actions/ui.expected.md
@@ -31,7 +31,7 @@
- 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}}
+- text: Available only on the last message of a completed turn 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "Read a.txt":
- img
- img
diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md
index 2d104f8e54..f470df2a28 100644
--- a/snapshots/web/minimal-preset/ui.expected.md
+++ b/snapshots/web/minimal-preset/ui.expected.md
@@ -33,7 +33,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md
index c6b3239426..fa0facf822 100644
--- a/snapshots/web/plan-review/approved-expanded.expected.md
+++ b/snapshots/web/plan-review/approved-expanded.expected.md
@@ -54,7 +54,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md
index 9c6a0e09e5..8135c83a0f 100644
--- a/snapshots/web/plan-review/approved.expected.md
+++ b/snapshots/web/plan-review/approved.expected.md
@@ -34,7 +34,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md
index da262e8712..7c0a05c6c7 100644
--- a/snapshots/web/question-composer/answered-expanded.expected.md
+++ b/snapshots/web/question-composer/answered-expanded.expected.md
@@ -46,7 +46,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md
index 67524dbc79..252ca47499 100644
--- a/snapshots/web/question-composer/answered.expected.md
+++ b/snapshots/web/question-composer/answered.expected.md
@@ -28,7 +28,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md
index ccacb6dd7b..66769e0488 100644
--- a/snapshots/web/queue-actions/preserved.expected.md
+++ b/snapshots/web/queue-actions/preserved.expected.md
@@ -29,7 +29,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}}
+- text: {{clock}} Ran for {{duration}} TTFT {{duration}}
- button "2 queued messages" [expanded]
- list:
- listitem:
diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md
index 5fc1034229..8f6b5b5e95 100644
--- a/snapshots/web/seeded-history/command-row.expected.md
+++ b/snapshots/web/seeded-history/command-row.expected.md
@@ -47,7 +47,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}}
+- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md
index d8a4bdf483..17eb33963d 100644
--- a/snapshots/web/seeded-history/feedback-row.expected.md
+++ b/snapshots/web/seeded-history/feedback-row.expected.md
@@ -47,7 +47,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}}
+- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md
index 38e1f2a261..28e2a90ec2 100644
--- a/snapshots/web/seeded-history/ui-expanded.expected.md
+++ b/snapshots/web/seeded-history/ui-expanded.expected.md
@@ -47,7 +47,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md
index b5819db45d..5167001259 100644
--- a/snapshots/web/seeded-history/ui.expected.md
+++ b/snapshots/web/seeded-history/ui.expected.md
@@ -29,7 +29,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: 7/25 {{clock}} Ran for {{duration}}
+- text: 7/25 {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md":
- img
diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md
index ce5851b7e9..21e4c5c810 100644
--- a/snapshots/web/skill-tool-row/ui.expected.md
+++ b/snapshots/web/skill-tool-row/ui.expected.md
@@ -47,7 +47,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md
index 44e93167a3..f7a0b9b006 100644
--- a/snapshots/web/steering/settled-expanded.expected.md
+++ b/snapshots/web/steering/settled-expanded.expected.md
@@ -39,7 +39,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md
index a650f1e211..62ed474ccf 100644
--- a/snapshots/web/steering/settled.expected.md
+++ b/snapshots/web/steering/settled.expected.md
@@ -31,7 +31,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md
index 71707d3523..ac152fe9ed 100644
--- a/snapshots/web/subagent-conversation/ui-expanded.expected.md
+++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md
@@ -44,7 +44,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "System prompt":
- img
- img
@@ -68,7 +68,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md
index 6474f5f98a..7212aa8d18 100644
--- a/snapshots/web/subagent-conversation/ui.expected.md
+++ b/snapshots/web/subagent-conversation/ui.expected.md
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- button "System prompt":
- img
- img
@@ -56,7 +56,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md
index fd12bf82ca..c7dd77a4a3 100644
--- a/snapshots/web/turn-tail-actions/completed.expected.md
+++ b/snapshots/web/turn-tail-actions/completed.expected.md
@@ -20,10 +20,6 @@
- text: 1 tool call · 1 message
- img
- paragraph: DONE
-- button "Turn usage 15.8K tok · Cache hit 49.7%":
- - img
- - img
- - text: Turn usage 15.8K tok · Cache hit 49.7%
- button "Copy":
- img
- button "Good response":
@@ -32,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Usage 15.8K tok · Cache hit 49.7%":
+ - img
+ - text: Usage 15.8K tok · Cache hit 49.7%
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md
index e313ab41fc..f8964fa6d7 100644
--- a/snapshots/web/turn-tail-actions/focused.expected.md
+++ b/snapshots/web/turn-tail-actions/focused.expected.md
@@ -33,10 +33,6 @@
- img
- text: Bash Print alpha to stdout
- paragraph: DONE
-- button "Turn usage 15.8K tok · Cache hit 49.7%":
- - img
- - img
- - text: Turn usage 15.8K tok · Cache hit 49.7%
- button "Copy":
- img
- button "Good response":
@@ -45,7 +41,10 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Usage 15.8K tok · Cache hit 49.7%":
+ - img
+ - text: Usage 15.8K tok · Cache hit 49.7%
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md
index a711875317..90d71c1a86 100644
--- a/snapshots/web/turn-tail-actions/settled.expected.md
+++ b/snapshots/web/turn-tail-actions/settled.expected.md
@@ -30,7 +30,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md
index ba9a3f5c1f..466d21613a 100644
--- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md
+++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md
@@ -20,19 +20,6 @@
- text: 1 tool call · 1 message
- img
- paragraph: DONE
-- button "Turn usage 15.8K tok · Cache hit 49.7%" [expanded]:
- - img
- - text: Turn usage 15.8K tok · Cache hit 49.7%
-- term: Provider / model
-- definition: deepseek-official/deepseek-v4-flash
-- term: Uncached input
-- definition: 7,891 tok
-- term: Cached input
-- definition: 7,808 tok
-- term: Output
-- definition: 112 tok (42 tok reasoning)
-- term: Total
-- definition: 15,811 tok
- button "Copy":
- img
- button "Good response":
@@ -41,7 +28,10 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- button "Usage 15.8K tok · Cache hit 49.7%" [expanded]:
+ - img
+ - text: Usage 15.8K tok · Cache hit 49.7%
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md
index 3b9f58931d..5ceba38e31 100644
--- a/snapshots/web/web-search-round/ui.expected.md
+++ b/snapshots/web/web-search-round/ui.expected.md
@@ -36,7 +36,7 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
- textbox "Message or run a task... / commands, @ files or sessions"
- button "Commands":
- img
diff --git a/snapshots/web/workflow-run/ui.expected.md b/snapshots/web/workflow-run/ui.expected.md
index 027175c4d6..7f47cb6d63 100644
--- a/snapshots/web/workflow-run/ui.expected.md
+++ b/snapshots/web/workflow-run/ui.expected.md
@@ -36,4 +36,4 @@
- img
- button "Branch into a new conversation":
- img
-- text: {{clock}} Ran for {{duration}}
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}
From 9effa0c6b3ab5682b634df98092dd179cfa95ff3 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Fri, 28 Aug 2026 01:34:18 +0800
Subject: [PATCH 4/8] feat(ui-chat): split turn stats into usage and time pills
with dialogs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../conversation-expanded.expected.md | 5 +-
.../conversation.expected.md | 5 +-
.../markdown-cjk-strong/ui.expected.md | 5 +-
.../expected/markdown-images/ui.expected.md | 5 +-
.../markdown-inline-code-links/ui.expected.md | 5 +-
.../expected/math-rendering/ui.expected.md | 5 +-
.../skill-user-invoke/ui-expanded.expected.md | 5 +-
.../expected/skill-user-invoke/ui.expected.md | 5 +-
.../stats-paged-history/ui.expected.md | 140 ++++++++++---
.../steer-all/settled-expanded.expected.md | 5 +-
.../expected/steer-all/settled.expected.md | 5 +-
apps/web/tests/turn-tail-actions.e2e.ts | 20 +-
.../client/chat/MessageIconActions.module.css | 26 +--
.../src/client/chat/MessageIconActions.tsx | 42 +---
.../src/client/chat/TurnTailNodeView.tsx | 43 +++-
.../src/client/chat/TurnUsagePanel.module.css | 95 +++++++--
.../src/client/chat/TurnUsagePanel.tsx | 198 ++++++++++++++++--
packages/client/ui-chat/src/client/locale.ts | 10 +-
.../ui-chat/tests/chat-view.client.spec.tsx | 70 ++++++-
.../tests/turn-usage-panel.client.spec.tsx | 98 ++++++++-
.../client/ui-primitives/src/icons/index.tsx | 17 ++
snapshots/web/code-mode-round/ui.expected.md | 5 +-
.../web/cordis-tool-round/ui.expected.md | 15 +-
.../feedback-command/ack-expanded.expected.md | 5 +-
.../web/feedback-command/ack.expected.md | 5 +-
.../feedback-release/ack-expanded.expected.md | 5 +-
.../web/feedback-release/ack.expected.md | 5 +-
.../fresh-round-trip/ui-expanded.expected.md | 5 +-
snapshots/web/fresh-round-trip/ui.expected.md | 5 +-
.../ui-expanded.expected.md | 10 +-
.../goal-multi-turn-actions/ui.expected.md | 10 +-
.../reloaded-expanded.expected.md | 5 +-
.../web/lifecycle-chrome/reloaded.expected.md | 5 +-
.../cancel-expanded.expected.md | 5 +-
.../web/live-interactions/cancel.expected.md | 5 +-
.../retry-expanded.expected.md | 5 +-
.../web/live-interactions/retry.expected.md | 5 +-
snapshots/web/message-actions/ui.expected.md | 11 +-
snapshots/web/minimal-preset/ui.expected.md | 5 +-
.../plan-review/approved-expanded.expected.md | 5 +-
.../web/plan-review/approved.expected.md | 5 +-
.../answered-expanded.expected.md | 5 +-
.../question-composer/answered.expected.md | 5 +-
.../preserved-expanded.expected.md | 5 +-
.../web/queue-actions/preserved.expected.md | 5 +-
.../seeded-history/command-row.expected.md | 5 +-
.../seeded-history/feedback-row.expected.md | 5 +-
.../seeded-history/ui-expanded.expected.md | 5 +-
snapshots/web/seeded-history/ui.expected.md | 5 +-
snapshots/web/skill-tool-row/ui.expected.md | 5 +-
.../web/steering/settled-expanded.expected.md | 5 +-
snapshots/web/steering/settled.expected.md | 5 +-
.../ui-expanded.expected.md | 10 +-
.../web/subagent-conversation/ui.expected.md | 10 +-
.../turn-tail-actions/completed.expected.md | 9 +-
.../web/turn-tail-actions/focused.expected.md | 9 +-
.../web/turn-tail-actions/settled.expected.md | 5 +-
.../usage-expanded.expected.md | 9 +-
snapshots/web/web-search-round/ui.expected.md | 5 +-
snapshots/web/workflow-run/ui.expected.md | 5 +-
60 files changed, 834 insertions(+), 218 deletions(-)
diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
index 1459a2aa8a..3a29a14b51 100644
--- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
+++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md
@@ -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
diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md
index c4f9670888..0a5fe67b90 100644
--- a/apps/web/tests/expected/github-ready-review/conversation.expected.md
+++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md
@@ -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
diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
index 65c73793b2..2fba524c5d 100644
--- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
+++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
@@ -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
diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md
index 3c6a9475f8..3843143d1f 100644
--- a/apps/web/tests/expected/markdown-images/ui.expected.md
+++ b/apps/web/tests/expected/markdown-images/ui.expected.md
@@ -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
diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
index 228443ee1e..2d2127cbb2 100644
--- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
+++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md
@@ -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
diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md
index e063215b77..fba05ffe71 100644
--- a/apps/web/tests/expected/math-rendering/ui.expected.md
+++ b/apps/web/tests/expected/math-rendering/ui.expected.md
@@ -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
diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
index f06169c311..07be7102ff 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
@@ -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
diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
index 3281b27094..7ddf071159 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
@@ -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
diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md
index dc2200a0b3..fc8039e282 100644
--- a/apps/web/tests/expected/stats-paged-history/ui.expected.md
+++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md
@@ -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"
diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
index c220ca0d5d..73e987dfa2 100644
--- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md
+++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
@@ -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
diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md
index 7a9146a5b5..cd48c29075 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -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
diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts
index 99b2572d60..40f5f0e017 100644
--- a/apps/web/tests/turn-tail-actions.e2e.ts
+++ b/apps/web/tests/turn-tail-actions.e2e.ts
@@ -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)
diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
index 37b48b77dc..2f6ec71c85 100644
--- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
+++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
@@ -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
diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
index b53ff20ae6..5476f1a5c0 100644
--- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
+++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
@@ -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 : (
- {meta.map((segment, index) => (
-
- {index > 0 && (
- <>
- {' '}
- ·
- {' '}
- >
- )}
- {segment}
-
- ))}
+ {formatMessageClock(time, t, day)}
)
return (
@@ -137,9 +108,6 @@ export function MessageIconActions({
{t('message.branchUnavailable')}
)}
{usageAction}
- {usageAction != null && clock === 'end' && clockEl !== null && (
- ·
- )}
{clock === 'end' ? clockEl : null}
)
diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
index 874eff5712..c54a093c36 100644
--- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
@@ -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}
{ forkAt(closing.finalNode.seq) }}
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
- usageAction={data.tokenUsage === undefined ? null : (
-
- )}
+ usageAction={flatVariant
+ ? data.tokenUsage !== undefined && (
+
+ )
+ : (
+ <>
+ {data.tokenUsage !== undefined && }
+ {runMs !== undefined && (
+
+ )}
+ >
+ )}
t={t}
/>
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
index f95534d7c0..d0af40f41f 100644
--- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
+++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
@@ -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;
-}
diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
index 79cbb6617b..7ef5d5489a 100644
--- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
+++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
@@ -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
+ panelRef: MutableRefObject
+ 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(null)
const panelRef = useRef(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 (
+
+ {runMs !== undefined && (
+ <>
+ - {t('message.turnTime.duration')}
+ - {formatRunDuration(runMs, t)}
+ >
+ )}
+ {tokensPerSecond !== undefined && (
+ <>
+ - {t('message.turnTime.speed')}
+ - {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
+ >
+ )}
+ {ttftMs !== undefined && (
+ <>
+ - {t('message.turnTime.ttft')}
+ - {t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}
+ >
+ )}
+
+ )
+}
+
+/**
+ * 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 (
{open && createPortal(
-
{t('message.turnUsage.title')}
+
+
+
+ {t('message.turnUsage.title')}
+
+ {formatExactCount(usage.totalTokens, t)}
+
+
{routes !== '' && (
<>
@@ -144,10 +247,61 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
)}
-
- - {t('message.turnUsage.total')}
- - {formatExactCount(usage.totalTokens, t)}
+ {variant === 'flat' && hasTimeFacts && (
+ <>
+
+
+
+ {t('message.turnTime.title')}
+
+
+
+
+ >
+ )}
+
,
+ document.body,
+ )}
+
+ )
+}
+
+/**
+ * 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 (
+
+
+ {open && createPortal(
+
+
+
+
+ {t('message.turnTime.title')}
+
+
+
+
,
document.body,
)}
diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts
index cb34f59c7c..9236610d4f 100644
--- a/packages/client/ui-chat/src/client/locale.ts
+++ b/packages/client/ui-chat/src/client/locale.ts
@@ -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…',
diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
index ef60154e61..301f788590 100644
--- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx
+++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx
@@ -1562,19 +1562,71 @@ describe('ChatView', () => {
}]]),
})
const view = render()
- // 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()
+ 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()
- // 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()
})
diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
index e97b88af10..596005c844 100644
--- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
+++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
@@ -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()
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()
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(
+ ,
+ )
+ 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()
+ 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(
+ ,
+ )
+ 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()
+ 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')
+ })
})
diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx
index 6f0a913f68..34bd5a8e13 100644
--- a/packages/client/ui-primitives/src/icons/index.tsx
+++ b/packages/client/ui-primitives/src/icons/index.tsx
@@ -761,6 +761,23 @@ export const IconDataOutline16 = ({ size = 16, className }: IconProps) => (
)
+/** IconDataOutline16 without its gear: a three-tier database cylinder. */
+export const IconDatabaseOutline16 = ({ size = 16, className }: IconProps) => (
+
+)
+
+/** Thin-stroke clock: outlined dial with square-cut hour and minute hands. */
+export const IconClockOutline16 = ({ size = 16, className }: IconProps) => (
+
+)
+
/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */
export const IconSendOutline14 = ({ size = 14, className }: IconProps) => (