Browse Source

refactor(ui-chat): drop the flat usage-variant debug switch and square narrow pills

The A/B test settled on the twin-pill layout, so the TEMPORARY
?usage-variant=flat trigger, its locale keys, and its tests leave with it.
Below 480px the stat pills now take the sibling action-button geometry so
their bare icons keep the row's rhythm instead of drifting on the wider
label padding and the -6px pair rebate.
Yif 1 month ago
parent
commit
6f16d5868c

+ 14 - 34
packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx

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

+ 14 - 36
packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css

@@ -43,36 +43,6 @@
   text-overflow: ellipsis;
 }
 
-/* TEMPORARY (usage-variant debug): the flat whole-line trigger reads like the
-   plain meta text, brightening on hover to hint clickability. The margin
-   widens the row's 8px flex gap off the branch icon beside it. */
-.flatTrigger {
-  min-width: 0;
-  margin-left: 6px;
-  padding: 0;
-  border: none;
-  background: transparent;
-  color: var(--dsw-alias-label-tertiary);
-  font-size: var(--dsh-content-font-size-secondary, 13px);
-  font-variant-numeric: tabular-nums;
-  line-height: calc(24px + var(--dsh-content-font-delta, 0px));
-  white-space: nowrap;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  cursor: pointer;
-}
-
-.flatTrigger:hover,
-.flatTrigger[aria-expanded='true'] {
-  color: var(--dsw-alias-label-secondary);
-}
-
-/* TEMPORARY (usage-variant debug): flat-line separators breathe wider than the
-   plain meta line's 5px runTimeDot rhythm. */
-.flatDot {
-  margin: 0 8px;
-}
-
 .trigger svg {
   width: calc(15px + var(--dsh-content-font-delta, 0px));
   height: calc(15px + var(--dsh-content-font-delta, 0px));
@@ -85,11 +55,24 @@
   color: var(--dsw-alias-label-secondary);
 }
 
-/* Narrow viewport: the pills collapse to bare icons; the dialogs keep the words. */
+/* Narrow viewport: the pills collapse to bare icons with the sibling
+   `.action` geometry (28px circle, 6px padding, centered glyph); the
+   label-padding rebate no longer applies, so the pair keeps the row's plain
+   8px rhythm. The dialogs keep the words. */
 @media (max-width: 480px) {
+  .trigger {
+    justify-content: center;
+    width: calc(28px + var(--dsh-content-font-delta, 0px));
+    padding: 6px;
+  }
+
   .trigger .label {
     display: none;
   }
+
+  .root + .root {
+    margin-left: 0;
+  }
 }
 
 /* Portal surface: fixed in the viewport, left/top supplied inline from the
@@ -128,11 +111,6 @@
   font-weight: 500;
 }
 
-/* Second-section heading (Turn time), spaced well off the usage rows above. */
-.timeTitle {
-  margin-top: 20px;
-}
-
 /* Rule under each section heading, above its rows. */
 .titleRule {
   margin-bottom: 10px;

+ 24 - 91
packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx

@@ -2,36 +2,20 @@
 // click-opens the per-Turn usage dialog, and a clock pill labelled with the
 // turn wall time click-opens the Turn-time dialog. Both sit right of the
 // branch action in the tail's IconActions row, ahead of the plain clock text.
-// TEMPORARY: the `flat` variant (usage-variant debug switch) renders the
-// whole meta line as one trigger whose dialog stacks both sections; one of
-// the two variants will be deleted after user testing.
 
-import { Fragment, useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
+import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
 import { createPortal } from 'react-dom'
 import {
   IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
 import type { ChatViewSlotProps } from '../contract/slots.ts'
-import {
-  formatLatencySeconds, formatMessageClock, formatRunDuration, formatTokensPerSecond,
-} from './message-chrome.ts'
+import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
 import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
-import { useCalendarDay } from './use-calendar-day.ts'
 import css from './TurnUsagePanel.module.css'
 
 export interface TurnUsagePanelProps {
   usage: TurnTokenUsage
-  /** Trigger form: the default icon pill or the temporary flat whole-line text. */
-  variant?: 'pill' | 'flat'
-  /** Unix epoch ms for the flat trigger's clock segment. */
-  time?: number | undefined
-  /** Turn wall time in ms; the flat trigger and its dialog's Turn-time section show it. */
-  runMs?: number | undefined
-  /** Turn decode throughput; the flat trigger and its dialog's Turn-time section show it. */
-  tokensPerSecond?: number | undefined
-  /** Turn first-step TTFT in ms; the flat trigger and its dialog's Turn-time section show it. */
-  ttftMs?: number | undefined
   /** The owning view's locale seat, passed down as a plain prop. */
   t: ChatViewSlotProps['t']
 }
@@ -115,85 +99,31 @@ function useStatDialog(): StatDialogSeat {
   return { open, setOpen, rootRef, panelRef, pos }
 }
 
-/** Turn-time dialog rows: run time plus throughput and TTFT when known. */
-function TimeDetails({ runMs, tokensPerSecond, ttftMs, t }: {
-  runMs: number | undefined
-  tokensPerSecond: number | undefined
-  ttftMs: number | undefined
-  t: ChatViewSlotProps['t']
-}) {
-  return (
-    <dl className={css.details} data-turn-time-details>
-      {runMs !== undefined && (
-        <>
-          <dt>{t('message.turnTime.duration')}</dt>
-          <dd>{formatRunDuration(runMs, t)}</dd>
-        </>
-      )}
-      {tokensPerSecond !== undefined && (
-        <>
-          <dt>{t('message.turnTime.speed')}</dt>
-          <dd>{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}</dd>
-        </>
-      )}
-      {ttftMs !== undefined && (
-        <>
-          <dt>{t('message.turnTime.ttft')}</dt>
-          <dd>{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}</dd>
-        </>
-      )}
-    </dl>
-  )
-}
-
 /**
- * Turn-usage IconActions pill (or the temporary flat meta-line trigger) with
- * a click-open Turn-usage details dialog.
- * @param props - Turn usage buckets, trigger form, and locale seat.
+ * Turn-usage IconActions pill with a click-open Turn-usage details dialog.
+ * @param props - Turn usage buckets and locale seat.
  * @returns The trigger and, while open, its portaled dialog anchored above the trigger.
  */
-export function TurnUsagePanel({ usage, variant = 'pill', time, runMs, tokensPerSecond, ttftMs, t }: TurnUsagePanelProps) {
+export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
   const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
-  const day = useCalendarDay()
 
   const cacheHit = usage.cacheReadTokens === undefined
     ? null
     : formatCacheHitPercent(usage.cacheReadTokens, usage.totalTokens - usage.outputTokens, 1)
   const total = formatCompactCount(usage.totalTokens, t)
   const routes = usage.routes?.map(route => `${route.provider}/${route.model}`).join(', ') ?? ''
-  const flatSegments: string[] = []
-  if (variant === 'flat') {
-    if (time !== undefined) flatSegments.push(formatMessageClock(time, t, day))
-    if (runMs !== undefined) flatSegments.push(t('message.ranFor', { duration: formatRunDuration(runMs, t) }))
-    flatSegments.push(t('message.turnUsage.consumed', { total }))
-    if (cacheHit !== null) flatSegments.push(t('message.turnUsage.cacheHitRate', { percent: cacheHit }))
-    if (tokensPerSecond !== undefined) {
-      flatSegments.push(t('message.turnUsage.speed', { tps: formatTokensPerSecond(tokensPerSecond) }))
-    }
-    if (ttftMs !== undefined) flatSegments.push(t('message.ttft', { seconds: formatLatencySeconds(ttftMs) }))
-  }
-  const hasTimeFacts = runMs !== undefined || tokensPerSecond !== undefined || ttftMs !== undefined
 
   return (
     <span ref={rootRef} className={css.root}>
       <button
         type="button"
-        className={variant === 'flat' ? css.flatTrigger : css.trigger}
+        className={css.trigger}
         aria-haspopup="dialog"
         aria-expanded={open}
         onClick={() => { setOpen(!open) }}
       >
-        {variant === 'flat' ? flatSegments.map((segment, index) => (
-          <Fragment key={index}>
-            {index > 0 && <span className={css.flatDot} aria-hidden>·</span>}
-            {segment}
-          </Fragment>
-        )) : (
-          <>
-            <IconDatabaseOutline16 />
-            <span className={css.label}>{t('message.turnUsage.consumed', { total })}</span>
-          </>
-        )}
+        <IconDatabaseOutline16 />
+        <span className={css.label}>{t('message.turnUsage.consumed', { total })}</span>
       </button>
       {open && createPortal(
         <div
@@ -248,18 +178,6 @@ export function TurnUsagePanel({ usage, variant = 'pill', time, runMs, tokensPer
               )}
             </dd>
           </dl>
-          {variant === 'flat' && hasTimeFacts && (
-            <>
-              <div className={`${css.title} ${css.timeTitle}`}>
-                <span className={css.titleLabel}>
-                  <IconClockOutline16 />
-                  {t('message.turnTime.title')}
-                </span>
-              </div>
-              <div className={css.titleRule} aria-hidden />
-              <TimeDetails runMs={runMs} tokensPerSecond={tokensPerSecond} ttftMs={ttftMs} t={t} />
-            </>
-          )}
         </div>,
         document.body,
       )}
@@ -301,7 +219,22 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan
             </span>
           </div>
           <div className={css.titleRule} aria-hidden />
-          <TimeDetails runMs={runMs} tokensPerSecond={tokensPerSecond} ttftMs={ttftMs} t={t} />
+          <dl className={css.details} data-turn-time-details>
+            <dt>{t('message.turnTime.duration')}</dt>
+            <dd>{formatRunDuration(runMs, t)}</dd>
+            {tokensPerSecond !== undefined && (
+              <>
+                <dt>{t('message.turnTime.speed')}</dt>
+                <dd>{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}</dd>
+              </>
+            )}
+            {ttftMs !== undefined && (
+              <>
+                <dt>{t('message.turnTime.ttft')}</dt>
+                <dd>{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}</dd>
+              </>
+            )}
+          </dl>
         </div>,
         document.body,
       )}

+ 0 - 6
packages/client/ui-chat/src/client/locale.ts

@@ -88,12 +88,9 @@ export const zh = {
   'message.maxTokens': '已达到输出 token 上限',
   'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
   'message.ranFor': '用时 {duration}',
-  'message.ttft': '首 token {seconds}秒',
   'message.tokensPerSecond': '{tps} tok/s',
   'message.turnUsage.title': '本轮用量',
   'message.turnUsage.consumed': '用量 {total}',
-  'message.turnUsage.cacheHitRate': '缓存命中 {percent}%',
-  'message.turnUsage.speed': '速度 {tps} tok/s',
   'message.turnUsage.model': '提供方 / 模型',
   'message.turnUsage.cacheHit': '缓存命中',
   'message.turnUsage.input': '未缓存输入',
@@ -207,12 +204,9 @@ export const en = {
   'message.maxTokens': 'Output token limit reached',
   'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
   'message.ranFor': 'Ran for {duration}',
-  'message.ttft': 'TTFT {seconds}s',
   'message.tokensPerSecond': '{tps} tok/s',
   'message.turnUsage.title': 'Turn usage',
   'message.turnUsage.consumed': 'Usage {total}',
-  'message.turnUsage.cacheHitRate': 'Cache hit {percent}%',
-  'message.turnUsage.speed': '{tps} tok/s',
   'message.turnUsage.model': 'Provider / model',
   'message.turnUsage.cacheHit': 'Cache hit',
   'message.turnUsage.input': 'Uncached input',

+ 14 - 0
packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts

@@ -117,6 +117,20 @@ describe('chat flow font-size axis', () => {
     ]))
   })
 
+  it('narrow viewports collapse the stat pills to the action-button circle', () => {
+    // Below 480px the label hides and the pill takes the sibling `.action`
+    // geometry (28px width, 6px padding, centered glyph); the -6px
+    // label-padding rebate between adjacent pills resets so the icon pair
+    // keeps the row's plain 8px rhythm instead of overlapping.
+    const css = read('TurnUsagePanel.module.css')
+    const narrow = /@media \(max-width: 480px\) \{([\s\S]*?)\n\}/.exec(css)?.[1] ?? ''
+    expect(narrow).toMatch(/\.trigger \{[^}]*justify-content: center/)
+    expect(narrow).toMatch(/\.trigger \{[^}]*width: calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
+    expect(narrow).toMatch(/\.trigger \{[^}]*padding: 6px/)
+    expect(narrow).toMatch(/\.trigger \.label \{[^}]*display: none/)
+    expect(narrow).toMatch(/\.root \+ \.root \{[^}]*margin-left: 0/)
+  })
+
   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

+ 0 - 43
packages/client/ui-chat/tests/chat-view.client.spec.tsx

@@ -1586,49 +1586,6 @@ describe('ChatView', () => {
     expect(timeDialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒')
   })
 
-  it('TEMPORARY: ?usage-variant=flat renders the whole meta line as the dialog trigger', () => {
-    window.history.replaceState(null, '', '?usage-variant=flat')
-    try {
-      const first: AssistantMessageNode = {
-        kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'draft' }],
-        timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
-        usage: { outputTokens: 40 },
-      }
-      const second: AssistantMessageNode = {
-        kind: 'assistant', seq: 16, time: 16_000, turn: 1, step: 2, blocks: [{ kind: 'text', text: 'final' }],
-        timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
-        usage: { outputTokens: 60 },
-      }
-      const h = makeHarness({
-        nodes: [user(1, 'hi'), first, second],
-        turnTimings: new Map([[1, { startTime: 1_000, endTime: 20_000 }]]),
-        turnEnds: new Map([[1, 20]]),
-        turnUsages: new Map([[1, {
-          uncachedInputTokens: 5_060,
-          cacheReadTokens: 4_940,
-          outputTokens: 100,
-          totalTokens: 10_100,
-        }]]),
-      })
-      const view = render(<h.ChatView {...h.props} />)
-      const trigger = view.getByRole('button', { name: /用量 10\.1K tok/ })
-      // Separator dots live in spaced spans, so textContent carries bare `·`.
-      expect(trigger.textContent)
-        .toMatch(/^.+·用时 19秒·用量 10\.1K tok·缓存命中 49\.4%·速度 20 tok\/s·首 token 1\.2秒$/)
-      // The flat trigger absorbs the meta line, so no plain copy remains.
-      expect(view.getByText(/用时 19秒/)).toBe(trigger)
-      fireEvent.click(trigger)
-      const dialog = view.getByRole('dialog')
-      expect(dialog.firstChild?.textContent).toBe('本轮用量10,100 tok')
-      expect(dialog.textContent).toContain('本轮用时和速度')
-      expect(dialog.textContent).toContain('本轮总用时19秒')
-      expect(dialog.textContent).toContain('输出速度(TPS)20 tok/s')
-      expect(dialog.textContent).toContain('首 token 平均用时(TTFT)1.2秒')
-    } finally {
-      window.history.replaceState(null, '', window.location.pathname)
-    }
-  })
-
   it('withholds the usage-details trigger when turn usage is outside the window', () => {
     const settled: AssistantMessageNode = {
       kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'answer' }],

+ 0 - 49
packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx

@@ -95,55 +95,6 @@ describe('TurnUsagePanel', () => {
     fireEvent.pointerDown(document.body)
     expect(view.queryByRole('dialog')).toBeNull()
   })
-
-  it('TEMPORARY flat variant: the whole meta line triggers a dialog with usage and time sections', () => {
-    const usage: TurnTokenUsage = {
-      uncachedInputTokens: 5_060,
-      cacheReadTokens: 4_940,
-      outputTokens: 5_800,
-      totalTokens: 15_800,
-    }
-    const view = render(
-      <TurnUsagePanel
-        usage={usage}
-        variant="flat"
-        time={Date.now()}
-        runMs={19_000}
-        tokensPerSecond={20}
-        ttftMs={1_200}
-        t={t}
-      />,
-    )
-    const trigger = view.getByRole('button')
-    // Separator dots live in spaced spans, so textContent carries bare `·`.
-    expect(trigger.textContent)
-      .toMatch(/^.+·Ran for 19s·Usage 15\.8K tok·Cache hit 49\.4%·20 tok\/s·TTFT 1\.2s$/)
-    expect(trigger.querySelector('svg')).toBeNull()
-
-    fireEvent.click(trigger)
-    const dialog = view.getByRole('dialog')
-    expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok')
-    expect(dialog.textContent).toContain('Turn time and speed')
-    const timeDetails = dialog.querySelector('[data-turn-time-details]') as HTMLElement
-    expect(timeDetails.textContent).toContain('Total run time19s')
-    expect(timeDetails.textContent).toContain('Tokens per second (TPS)20 tok/s')
-    expect(timeDetails.textContent).toContain('Average time to first token (TTFT)1.2s')
-  })
-
-  it('TEMPORARY flat variant: absent time facts leave no Turn-time section', () => {
-    const usage: TurnTokenUsage = {
-      uncachedInputTokens: 120,
-      outputTokens: 30,
-      totalTokens: 150,
-    }
-    const view = render(<TurnUsagePanel usage={usage} variant="flat" t={t} />)
-    const trigger = view.getByRole('button')
-    expect(trigger.textContent).toBe('Usage 150 tok')
-    fireEvent.click(trigger)
-    const dialog = view.getByRole('dialog')
-    expect(dialog.textContent).not.toContain('Turn time')
-    expect(dialog.querySelector('[data-turn-time-details]')).toBeNull()
-  })
 })
 
 describe('TurnTimePanel', () => {