فهرست منبع

feat(ui-chat): reshape the usage trigger as an icon-row pill

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.
Yif 1 ماه پیش
والد
کامیت
f15078532f
54فایلهای تغییر یافته به همراه343 افزوده شده و 279 حذف شده
  1. 1 1
      apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md
  2. 1 1
      apps/web/tests/expected/skill-user-invoke/ui.expected.md
  3. 1 1
      apps/web/tests/expected/steer-all/settled-expanded.expected.md
  4. 1 1
      apps/web/tests/expected/steer-all/settled.expected.md
  5. 6 3
      apps/web/tests/turn-tail-actions.e2e.ts
  6. 3 1
      packages/client/ui-chat/package.json
  7. 25 20
      packages/client/ui-chat/src/client/chat/MessageIconActions.module.css
  8. 39 21
      packages/client/ui-chat/src/client/chat/MessageIconActions.tsx
  9. 15 3
      packages/client/ui-chat/src/client/chat/MessageItem.tsx
  10. 6 11
      packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx
  11. 50 25
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css
  12. 61 52
      packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx
  13. 6 20
      packages/client/ui-chat/src/client/locale.ts
  14. 7 1
      packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx
  15. 20 18
      packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts
  16. 18 13
      packages/client/ui-chat/tests/chat-view.client.spec.tsx
  17. 18 18
      packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx
  18. 11 9
      packages/client/ui-primitives/src/useAnchoredPosition.ts
  19. 6 0
      pnpm-lock.yaml
  20. 1 1
      snapshots/web/code-mode-round/ui.expected.md
  21. 1 1
      snapshots/web/feedback-command/ack-expanded.expected.md
  22. 1 1
      snapshots/web/feedback-command/ack.expected.md
  23. 1 1
      snapshots/web/feedback-release/ack-expanded.expected.md
  24. 1 1
      snapshots/web/feedback-release/ack.expected.md
  25. 1 1
      snapshots/web/fresh-round-trip/ui-expanded.expected.md
  26. 1 1
      snapshots/web/fresh-round-trip/ui.expected.md
  27. 2 2
      snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
  28. 2 2
      snapshots/web/goal-multi-turn-actions/ui.expected.md
  29. 1 1
      snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
  30. 1 1
      snapshots/web/lifecycle-chrome/reloaded.expected.md
  31. 1 1
      snapshots/web/live-interactions/retry-expanded.expected.md
  32. 1 1
      snapshots/web/live-interactions/retry.expected.md
  33. 1 1
      snapshots/web/message-actions/ui.expected.md
  34. 1 1
      snapshots/web/minimal-preset/ui.expected.md
  35. 1 1
      snapshots/web/plan-review/approved-expanded.expected.md
  36. 1 1
      snapshots/web/plan-review/approved.expected.md
  37. 1 1
      snapshots/web/question-composer/answered-expanded.expected.md
  38. 1 1
      snapshots/web/question-composer/answered.expected.md
  39. 1 1
      snapshots/web/queue-actions/preserved.expected.md
  40. 1 1
      snapshots/web/seeded-history/command-row.expected.md
  41. 1 1
      snapshots/web/seeded-history/feedback-row.expected.md
  42. 1 1
      snapshots/web/seeded-history/ui-expanded.expected.md
  43. 1 1
      snapshots/web/seeded-history/ui.expected.md
  44. 1 1
      snapshots/web/skill-tool-row/ui.expected.md
  45. 1 1
      snapshots/web/steering/settled-expanded.expected.md
  46. 1 1
      snapshots/web/steering/settled.expected.md
  47. 2 2
      snapshots/web/subagent-conversation/ui-expanded.expected.md
  48. 2 2
      snapshots/web/subagent-conversation/ui.expected.md
  49. 4 5
      snapshots/web/turn-tail-actions/completed.expected.md
  50. 4 5
      snapshots/web/turn-tail-actions/focused.expected.md
  51. 1 1
      snapshots/web/turn-tail-actions/settled.expected.md
  52. 4 14
      snapshots/web/turn-tail-actions/usage-expanded.expected.md
  53. 1 1
      snapshots/web/web-search-round/ui.expected.md
  54. 1 1
      snapshots/web/workflow-run/ui.expected.md

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

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

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

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

+ 6 - 3
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)

+ 3 - 1
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:^"

+ 25 - 20
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. */
-@media (hover: hover) {
-  [data-time-hover-root] :is(.timeStart, .timeEnd) {
-    opacity: 0;
-    transition: opacity 80ms ease;
-  }
+/* 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);
+}
 
-  [data-time-hover-root]:hover :is(.timeStart, .timeEnd),
-  [data-time-hover-root]:focus-within :is(.timeStart, .timeEnd) {
-    opacity: 1;
-  }
+.actionsDot + .timeEnd {
+  padding-left: 0;
+}
 
-  /* 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. */
+/* 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-actions-reveal='hover'] .actions {
     opacity: 0;
     transition: opacity 80ms ease;

+ 39 - 21
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 : (
     <span className={clock === 'start' ? css.timeStart : css.timeEnd}>
-      {formatMessageClock(time, t, day)}
-      {runMs !== undefined && (
-        <>
-          {' '}
-          <span className={css.runTimeDot} aria-hidden>·</span>
-          {' '}
-          {t('message.ranFor', { duration: formatRunDuration(runMs, t) })}
-        </>
-      )}
+      {meta.map((segment, index) => (
+        <Fragment key={segment}>
+          {index > 0 && (
+            <>
+              {' '}
+              <span className={css.runTimeDot} aria-hidden>·</span>
+              {' '}
+            </>
+          )}
+          {segment}
+        </Fragment>
+      ))}
     </span>
   )
   return (
@@ -121,8 +136,11 @@ export function MessageIconActions({
       {onBranch !== undefined && branchUnavailable && (
         <span id={reasonId} className={css.visuallyHidden}>{t('message.branchUnavailable')}</span>
       )}
+      {usageAction}
+      {usageAction != null && clock === 'end' && clockEl !== null && (
+        <span className={css.actionsDot} aria-hidden>·</span>
+      )}
       {clock === 'end' ? clockEl : null}
-      {clock === 'end' ? usageDetails : null}
     </div>
   )
 }

+ 15 - 3
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}
     >
       <div className={css.userStack}>
         {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 (
     <UserStyleBubble
       content={data.content}
       renderMessageImages={renderMessageImages}
       {...data.referenceLabels === undefined ? {} : { referenceLabels: data.referenceLabels }}
+      reveal={isLatestUserRow ? 'always' : 'hover'}
       t={t}
       actions={text => (
         <MessageIconActions

+ 6 - 11
packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx

@@ -43,22 +43,17 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
       {tail}
       <MessageIconActions
         text={assistantText(closing.blocks)}
-        time={data.tokenUsage === undefined ? closing.time : undefined}
-        runMs={data.tokenUsage === undefined ? runMs : undefined}
+        time={closing.time}
+        runMs={runMs}
+        tokensPerSecond={data.tokensPerSecond}
+        ttftMs={data.ttftMs}
         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}
-          />
+        usageAction={data.tokenUsage === undefined ? null : (
+          <TurnUsagePanel usage={data.tokenUsage} t={t} />
         )}
         t={t}
       />

+ 50 - 25
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;
 }

+ 61 - 52
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
+
+/**
+ * Unplaced portal panel: hidden but laid out so the clamp measures real
+ * dimensions (the `useAnchoredPosition` measure pass).
+ */
+const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
+
 /**
- * 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.
+ * 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, time, runMs, ttftMs, tokensPerSecond, t }: TurnUsagePanelProps) {
+export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
   const [open, setOpen] = useState(false)
   const rootRef = useRef<HTMLSpanElement | null>(null)
-  const day = useCalendarDay()
+  const panelRef = useRef<HTMLDivElement | null>(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 (
     <span ref={rootRef} className={css.root}>
@@ -86,21 +92,22 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
         aria-expanded={open}
         onClick={() => { setOpen(!open) }}
       >
-        {metrics.map((metric, index) => (
-          <Fragment key={metric}>
-            {index > 0 && (
-              <>
-                {' '}
-                <span className={css.dot} aria-hidden>·</span>
-                {' '}
-              </>
-            )}
-            {metric}
-          </Fragment>
-        ))}
+        <IconDataOutline16 />
+        <span className={css.label}>
+          {cacheHit === null
+            ? t('message.turnUsage.consumed', { total })
+            : `${t('message.turnUsage.consumed', { total })} · ${t('message.turnUsage.cacheHitRate', { percent: cacheHit })}`}
+        </span>
       </button>
-      {open && (
-        <div className={css.panel} role="dialog" aria-label={t('message.turnUsage.title')}>
+      {open && createPortal(
+        <div
+          ref={panelRef}
+          className={css.panel}
+          role="dialog"
+          aria-label={t('message.turnUsage.title')}
+          style={pos ?? MEASURE_STYLE}
+        >
+          <div className={css.title}>{t('message.turnUsage.title')}</div>
           <dl className={css.details} data-turn-usage-details>
             {routes !== '' && (
               <>
@@ -108,6 +115,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
                 <dd className={css.route}>{routes}</dd>
               </>
             )}
+            {cacheHit !== null && (
+              <>
+                <dt>{t('message.turnUsage.cacheHit')}</dt>
+                <dd>{`${cacheHit}%`}</dd>
+              </>
+            )}
             <dt>{t('message.turnUsage.input')}</dt>
             <dd>{formatExactCount(usage.uncachedInputTokens, t)}</dd>
             {usage.cacheReadTokens !== undefined && (
@@ -131,16 +144,12 @@ export function TurnUsagePanel({ usage, time, runMs, ttftMs, tokensPerSecond, t
                 </span>
               )}
             </dd>
-            {cacheHit !== null && (
-              <>
-                <dt>{t('message.turnDetails.cacheHit')}</dt>
-                <dd>{`${cacheHit}%`}</dd>
-              </>
-            )}
+            <div className={css.separator} aria-hidden />
             <dt className={css.totalLabel}>{t('message.turnUsage.total')}</dt>
             <dd className={css.totalValue}>{formatExactCount(usage.totalTokens, t)}</dd>
           </dl>
-        </div>
+        </div>,
+        document.body,
       )}
     </span>
   )

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

+ 7 - 1
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<ConversationNode, { kind: 'model-retry' }>['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':

+ 20 - 18
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', () => {

+ 18 - 13
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -1562,18 +1562,18 @@ describe('ChatView', () => {
       }]]),
     })
     const view = render(<h.ChatView {...h.props} />)
-    // 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(<h.ChatView {...h.props} />)
-    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(<h.ChatView {...h.props} />)
-    // 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', () => {

+ 18 - 18
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(
-      <TurnUsagePanel usage={usage} time={TIME} runMs={13_000} ttftMs={2_000} tokensPerSecond={107} t={t} />,
-    )
+    const view = render(<TurnUsagePanel usage={usage} 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.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(<TurnUsagePanel usage={usage} time={TIME} t={t} />)
+    const view = render(<TurnUsagePanel usage={usage} 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()
+    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(<TurnUsagePanel usage={usage} time={TIME} t={t} />)
+    const view = render(<TurnUsagePanel usage={usage} t={t} />)
     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')

+ 11 - 9
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<HTMLElement | null>
   /** The floating element, measured so the clamp uses real dimensions. */
   panelRef: RefObject<HTMLElement | null>
-  /** 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<CSSProperties | null>(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
 }

+ 6 - 0
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:

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

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

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

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

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

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

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

+ 2 - 2
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"

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

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

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

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

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

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

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

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

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

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

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

+ 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}}
+- text: {{clock}} Ran for {{duration}} TTFT {{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}}
+- 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

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

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

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

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

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

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

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

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

+ 4 - 5
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

+ 4 - 5
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

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

+ 4 - 14
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

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

+ 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}}
+- text: {{clock}} Ran for {{duration}} {{throughput}} tok/s TTFT {{duration}}