Prechádzať zdrojové kódy

feat(ui-chat): collapse the turn tail into one clickable meta line

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.
Yif 1 mesiac pred
rodič
commit
bb1df10c69
23 zmenil súbory, kde vykonal 510 pridanie a 336 odobranie
  1. 15 11
      apps/web/tests/turn-tail-actions.e2e.ts
  2. 22 6
      packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
  3. 11 24
      packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
  4. 0 7
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.module.css
  5. 28 18
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  6. 0 88
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css
  7. 0 86
      packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx
  8. 99 0
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
  9. 147 0
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
  10. 20 0
      packages/client/ui-chat/src/client/locale.ts
  11. 54 9
      packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
  12. 5 0
      packages/client/ui-chat/tests/chat-snapshot-fixture.client.ts
  13. 0 76
      packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx
  14. 98 0
      packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
  15. 3 3
      packages/client/ui-message-feedback/src/client/MessageFeedbackActions.module.css
  16. 1 1
      packages/client/ui-message-feedback/tests/styles.client.spec.ts
  17. 1 1
      snapshots/web/feedback-command/ack.expected.md
  18. 1 1
      snapshots/web/message-actions/ui.expected.md
  19. 1 1
      snapshots/web/queue-actions/preserved.expected.md
  20. 1 1
      snapshots/web/seeded-history/command-row.expected.md
  21. 1 1
      snapshots/web/seeded-history/feedback-row.expected.md
  22. 1 1
      snapshots/web/seeded-history/ui.expected.md
  23. 1 1
      snapshots/web/workflow-run/ui.expected.md

+ 15 - 11
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)

+ 22 - 6
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 {

+ 11 - 24
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 : (
     <span className={clock === 'start' ? css.timeStart : css.timeEnd}>
       {formatMessageClock(time, t, day)}
@@ -89,22 +91,6 @@ export function MessageIconActions({
           {t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
         </>
       )}
-      {ttftMs !== undefined && (
-        <>
-          {' '}
-          <span className={css.runTimeDot} aria-hidden>·</span>
-          {' '}
-          {t('message.ttft', { seconds: formatLatencySeconds(ttftMs) })}
-        </>
-      )}
-      {tokensPerSecond !== undefined && (
-        <>
-          {' '}
-          <span className={css.runTimeDot} aria-hidden>·</span>
-          {' '}
-          {t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
-        </>
-      )}
     </span>
   )
   return (
@@ -136,6 +122,7 @@ export function MessageIconActions({
         <span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
       )}
       {clock === 'end' ? clockEl : null}
+      {clock === 'end' ? usageDetails : null}
     </div>
   )
 }

+ 0 - 7
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;
 }

+ 28 - 18
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 (
-    <div className={css.root} data-turn-tail={data.turn} data-time-hover-root>
+    <div
+      className={css.root}
+      data-turn-tail={data.turn}
+      data-actions-reveal={isLatestTurn ? 'always' : 'hover'}
+    >
       {tail}
-      <div className={css.footer}>
-        {data.tokenUsage === undefined ? null : <TurnUsageDisclosure usage={data.tokenUsage} t={t} />}
-        <MessageIconActions
-          text={assistantText(closing.blocks)}
-          time={closing.time}
-          runMs={runMs}
-          ttftMs={data.ttftMs}
-          tokensPerSecond={data.tokensPerSecond}
-          clock="end"
-          onBranch={() => { forkAt(closing.finalNode.seq) }}
-          branchUnavailable={data.branchUnavailable || hasLaterChatNode}
-          className={css.actions}
-          extraActions={assistantActions}
-          t={t}
-        />
-      </div>
+      <MessageIconActions
+        text={assistantText(closing.blocks)}
+        time={data.tokenUsage === undefined ? closing.time : undefined}
+        runMs={data.tokenUsage === undefined ? runMs : undefined}
+        clock="end"
+        onBranch={() => { forkAt(closing.finalNode.seq) }}
+        branchUnavailable={data.branchUnavailable || hasLaterChatNode}
+        className={css.actions}
+        extraActions={assistantActions}
+        usageDetails={data.tokenUsage === undefined ? null : (
+          <TurnUsagePanel
+            usage={data.tokenUsage}
+            time={closing.time}
+            runMs={runMs}
+            ttftMs={data.ttftMs}
+            tokensPerSecond={data.tokensPerSecond}
+            t={t}
+          />
+        )}
+        t={t}
+      />
     </div>
   )
 })

+ 0 - 88
packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.module.css

@@ -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;
-  }
-}

+ 0 - 86
packages/client/ui-chat/src/client/chat/TurnUsageDisclosure.tsx

@@ -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 (
-    <DisclosureRow
-      icon={<IconDataOutline16 />}
-      title={t('message.turnUsage.title')}
-      open={open}
-      expandable
-      onToggle={() => { setOpen(value => !value) }}
-      expandOnRowClick
-      keepContentWhenOpen
-      collapsedContent={(
-        <>
-          <span className={css.separator} aria-hidden />
-          <span className={css.summary}>{summary}</span>
-        </>
-      )}
-      className={css.root}
-      chevronClassName={css.chevron}
-    >
-      <dl className={css.details} data-turn-usage-details>
-        {routes !== '' && (
-          <>
-            <dt>{t('message.turnUsage.model')}</dt>
-            <dd className={css.route}>{routes}</dd>
-          </>
-        )}
-        <dt>{t('message.turnUsage.input')}</dt>
-        <dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
-        {usage.cacheReadTokens !== undefined && (
-          <>
-            <dt>{t('message.turnUsage.cacheRead')}</dt>
-            <dd>{formatExactCount(usage.cacheReadTokens, t)}</dd>
-          </>
-        )}
-        {usage.cacheWriteTokens !== undefined && (
-          <>
-            <dt>{t('message.turnUsage.cacheWrite')}</dt>
-            <dd>{formatExactCount(usage.cacheWriteTokens, t)}</dd>
-          </>
-        )}
-        <dt>{t('message.turnUsage.output')}</dt>
-        <dd>
-          {formatExactCount(usage.outputTokens, t)}
-          {usage.reasoningTokens !== undefined && (
-            <span className={css.reasoning}>
-              {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
-            </span>
-          )}
-        </dd>
-        <dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
-        <dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
-      </dl>
-    </DisclosureRow>
-  )
-}

+ 99 - 0
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;
+}

+ 147 - 0
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<HTMLSpanElement | null>(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 (
+    <span ref={rootRef} className={css.root}>
+      <button
+        type="button"
+        className={css.trigger}
+        aria-haspopup="dialog"
+        aria-expanded={open}
+        onClick={() => { setOpen(!open) }}
+      >
+        {metrics.map((metric, index) => (
+          <Fragment key={metric}>
+            {index > 0 && (
+              <>
+                {' '}
+                <span className={css.dot} aria-hidden>·</span>
+                {' '}
+              </>
+            )}
+            {metric}
+          </Fragment>
+        ))}
+      </button>
+      {open && (
+        <div className={css.panel} role="dialog" aria-label={t('message.turnUsage.title')}>
+          <dl className={css.details} data-turn-usage-details>
+            {routes !== '' && (
+              <>
+                <dt>{t('message.turnUsage.model')}</dt>
+                <dd className={css.route}>{routes}</dd>
+              </>
+            )}
+            <dt>{t('message.turnUsage.input')}</dt>
+            <dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
+            {usage.cacheReadTokens !== undefined && (
+              <>
+                <dt>{t('message.turnUsage.cacheRead')}</dt>
+                <dd>{formatExactCount(usage.cacheReadTokens, t)}</dd>
+              </>
+            )}
+            {usage.cacheWriteTokens !== undefined && (
+              <>
+                <dt>{t('message.turnUsage.cacheWrite')}</dt>
+                <dd>{formatExactCount(usage.cacheWriteTokens, t)}</dd>
+              </>
+            )}
+            <dt>{t('message.turnUsage.output')}</dt>
+            <dd>
+              {formatExactCount(usage.outputTokens, t)}
+              {usage.reasoningTokens !== undefined && (
+                <span className={css.reasoning}>
+                  {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
+                </span>
+              )}
+            </dd>
+            {cacheHit !== null && (
+              <>
+                <dt>{t('message.turnDetails.cacheHit')}</dt>
+                <dd>{`${cacheHit}%`}</dd>
+              </>
+            )}
+            <dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
+            <dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
+          </dl>
+        </div>
+      )}
+    </span>
+  )
+}

+ 20 - 0
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',

+ 54 - 9
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', () => {

+ 5 - 0
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<number, TurnTokenUsage> | 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<typeof assistantData>).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({

+ 0 - 76
packages/client/ui-chat/tests/turn-usage-disclosure.client.spec.tsx

@@ -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(<TurnUsageDisclosure usage={usage} t={t} />)
-
-    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(<TurnUsageDisclosure usage={usage} t={t} />)
-
-    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(<TurnUsageDisclosure usage={usage} t={t} />)
-    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')
-  })
-})

+ 98 - 0
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(
+      <TurnUsagePanel usage={usage} time={TIME} runMs={13_000} ttftMs={2_000} tokensPerSecond={107} t={t} />,
+    )
+
+    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(<TurnUsagePanel usage={usage} time={TIME} t={t} />)
+
+    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(<TurnUsagePanel usage={usage} time={TIME} t={t} />)
+    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()
+  })
+})

+ 3 - 3
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 {

+ 1 - 1
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\)\)/)
   })
 })

+ 1 - 1
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

+ 1 - 1
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

+ 1 - 1
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:

+ 1 - 1
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

+ 1 - 1
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

+ 1 - 1
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

+ 1 - 1
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}}