From 6f16d5868c4fb2c3eec7ad384048125e1d89b375 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 28 Aug 2026 02:14:37 +0800 Subject: [PATCH] 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. --- .../src/client/chat/TurnTailNodeView.tsx | 48 +++----- .../src/client/chat/TurnUsagePanel.module.css | 50 +++----- .../src/client/chat/TurnUsagePanel.tsx | 115 ++++-------------- packages/client/ui-chat/src/client/locale.ts | 6 - .../chat-font-axis-styles.client.spec.ts | 14 +++ .../ui-chat/tests/chat-view.client.spec.tsx | 43 ------- .../tests/turn-usage-panel.client.spec.tsx | 49 -------- 7 files changed, 66 insertions(+), 259 deletions(-) diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index c54a093c36..36c76b7c26 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/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} { forkAt(closing.finalNode.seq) }} branchUnavailable={data.branchUnavailable || hasLaterChatNode} className={css.actions} extraActions={assistantActions} - usageAction={flatVariant - ? data.tokenUsage !== undefined && ( - - ) - : ( - <> - {data.tokenUsage !== undefined && } - {runMs !== undefined && ( - - )} - - )} + usageAction={( + <> + {data.tokenUsage !== undefined && } + {runMs !== undefined && ( + + )} + + )} t={t} /> diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index d0af40f41f..e655a36810 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/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; diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index 7ef5d5489a..a8ab03f6ab 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/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 ( -
- {runMs !== undefined && ( - <> -
{t('message.turnTime.duration')}
-
{formatRunDuration(runMs, t)}
- - )} - {tokensPerSecond !== undefined && ( - <> -
{t('message.turnTime.speed')}
-
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
- - )} - {ttftMs !== undefined && ( - <> -
{t('message.turnTime.ttft')}
-
{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}
- - )} -
- ) -} - /** - * Turn-usage IconActions pill (or the temporary flat meta-line trigger) with - * a click-open Turn-usage details dialog. - * @param props - Turn usage buckets, trigger form, and locale seat. + * 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 ( {open && createPortal(
- {variant === 'flat' && hasTimeFacts && ( - <> -
- - - {t('message.turnTime.title')} - -
-
- - - )}
, document.body, )} @@ -301,7 +219,22 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan
- +
+
{t('message.turnTime.duration')}
+
{formatRunDuration(runMs, t)}
+ {tokensPerSecond !== undefined && ( + <> +
{t('message.turnTime.speed')}
+
{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}
+ + )} + {ttftMs !== undefined && ( + <> +
{t('message.turnTime.ttft')}
+
{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}
+ + )} +
, document.body, )} diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 9236610d4f..82a3329d05 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/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', diff --git a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts index a279c63c4d..4bbfe4dbba 100644 --- a/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-font-axis-styles.client.spec.ts @@ -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 diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 301f788590..6b72d28236 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/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() - 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' }], diff --git a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx index 596005c844..c9ef6d45f1 100644 --- a/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx +++ b/packages/client/ui-chat/tests/turn-usage-panel.client.spec.tsx @@ -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( - , - ) - const trigger = view.getByRole('button') - // Separator dots live in spaced spans, so textContent carries bare `·`. - expect(trigger.textContent) - .toMatch(/^.+·Ran for 19s·Usage 15\.8K tok·Cache hit 49\.4%·20 tok\/s·TTFT 1\.2s$/) - expect(trigger.querySelector('svg')).toBeNull() - - fireEvent.click(trigger) - const dialog = view.getByRole('dialog') - expect(dialog.firstChild?.textContent).toBe('Turn usage15,800 tok') - expect(dialog.textContent).toContain('Turn time and speed') - const timeDetails = dialog.querySelector('[data-turn-time-details]') as HTMLElement - expect(timeDetails.textContent).toContain('Total run time19s') - expect(timeDetails.textContent).toContain('Tokens per second (TPS)20 tok/s') - expect(timeDetails.textContent).toContain('Average time to first token (TTFT)1.2s') - }) - - it('TEMPORARY flat variant: absent time facts leave no Turn-time section', () => { - const usage: TurnTokenUsage = { - uncachedInputTokens: 120, - outputTokens: 30, - totalTokens: 150, - } - const view = render() - const trigger = view.getByRole('button') - expect(trigger.textContent).toBe('Usage 150 tok') - fireEvent.click(trigger) - const dialog = view.getByRole('dialog') - expect(dialog.textContent).not.toContain('Turn time') - expect(dialog.querySelector('[data-turn-time-details]')).toBeNull() - }) }) describe('TurnTimePanel', () => {