refactor(ui-chat): register composer stats pills as separate dock entries

Split the single 'stats' conversation.composer.dock entry into
'activity' (counts, speed, time dialog) and 'usage' (tokens, cache hit)
so another plugin can replace one pill by id instead of reimplementing
the whole row. The rendered pills are unchanged.
This commit is contained in:
turtle1999 committed 2026-10-01 17:47:35 +08:00
1 parent 7b658a9e94
commit 2190082866
17 files changed
+313 -281

No files matched your search

+6 -5
View File
@@ -330,7 +330,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('persists performance detail and hides statistics in Compact', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-performance-usage'))
const stats = page.locator('[data-composer-stats]')
const stats = page.locator('[data-composer-stat]')
const statsText = async (): Promise<string> => (await stats.allTextContents()).join(' ')
await openSettings(page, 'en')
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
const row = dialog.getByText('Performance & usage', { exact: true }).locator('../..')
@@ -339,11 +340,11 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ performanceUsage: 'compact' })
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
await expect.poll(() => stats.locator('button').count()).toBe(0)
expect(await stats.textContent()).not.toContain('turns')
expect(await stats.textContent()).toContain('Cache hit')
await stats.hover()
expect(await statsText()).not.toContain('turns')
expect(await statsText()).toContain('Cache hit')
await stats.first().hover()
expect(await page.getByRole('dialog').count()).toBe(0)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-stats]', scaffold.workspaceCwd), MODE)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-dock]', scaffold.workspaceCwd), MODE)
const warningStart = tripwire.warnings.length
await page.reload()
await openSettings(page, 'en')
+2 -2
View File
@@ -24,8 +24,8 @@
en: 2157fcd6b0d100b5
zh: 9e865ac3953ebe30
/deepseek-ai-dsh-client-ui-chat/turn-token-usage:
en: 11436f75c377ed1b
zh: 42707b8efc201dad
en: ad7fc783975fb3a4
zh: 5a17e5369cd7b328
/deepseek-ai-dsh-client-ui-chat/completed-turn-footer:
en: b4cd36e7ab91281e
zh: 8ccf412b9dde61d5
+2
View File
@@ -60,6 +60,8 @@ A completed Turn shows an expandable usage row only when the loaded window inclu
Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact.
The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). Another plugin replaces one pill by registering the same id at a lower `priority`, or adds a pill under a new id. Each pill carries its id in `data-composer-stat`. An open pill dialog closes when the pointer or focus moves outside it, so at most one dialog is open.
On non-loopback browsers, the preference remains process-local because the settings scope cannot persist writes. Explicit selections update every consumer immediately; accepted Host settings reconcile the live value on loopback browsers.
Preference menus restore focus to their trigger without scrolling before publishing a new selection.
+2
View File
@@ -60,6 +60,8 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。
输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。其他插件以更低的 `priority` 注册同一 id 即可替换某个胶囊,以新 id 注册则可新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。指针或焦点移到已打开的对话框之外时,该对话框会关闭,因此同时最多打开一个对话框。
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。
+11 -6
View File
@@ -29,7 +29,7 @@ import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
import { ApprovalCommand } from './chat/ApprovalCommand.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { StatsPills } from './chat/StatsPills.tsx'
import { ActivityPill, UsagePill } from './chat/StatsPills.tsx'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { QuotaNoticeHost } from './chat/QuotaNoticeHost.tsx'
import { en, NS, zh } from './locale.ts'
@@ -281,11 +281,16 @@ export function apply(ctx: Context): void {
}),
}, QuotaNoticeHost))
ctx.slots.inject('conversation.composer.dock', () =>
ctx.slots.register({
name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,
inject: () => ({ hooks: { performanceUsage } }),
}, StatsPills))
// One dock entry per pill, so a plugin replaces or adds a single pill by id.
const statPillInject = () => ({ hooks: { performanceUsage } })
ctx.slots.inject('conversation.composer.dock', function* () {
yield ctx.slots.register({
name: 'conversation.composer.dock', id: 'activity', order: 0, locale: NS, inject: statPillInject,
}, ActivityPill)
yield ctx.slots.register({
name: 'conversation.composer.dock', id: 'usage', order: 1, locale: NS, inject: statPillInject,
}, UsagePill)
})
ctx.slots.inject('conversation.approval.detail', () =>
ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand))
@@ -1,22 +1,14 @@
/* Two icon pills side by side under the composer, 12/20 tertiary text tier
with a hover-pill affordance; each pill's click-open panel wears the shared
stat-dialog.module.css skin. */
/* Icon pills under the composer, each one composer-dock entry laid out by the
dock's flex row; 12/20 tertiary text tier with a hover-pill affordance; each
pill's click-open panel wears the shared stat-dialog.module.css skin. */
.root {
display: flex;
justify-content: center;
gap: 12px;
min-width: 0;
max-width: 100%;
box-sizing: border-box;
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
/* Dialog anchor: shrink-wraps its pill so the panel clamp measures the pill. */
/* Entry root and dialog anchor: shrink-wraps its pill so the panel clamp
measures the pill. */
.anchor {
display: inline-flex;
min-width: 0;
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
.pill {
@@ -1,10 +1,10 @@
// Composer statistics: Compact exposes speed and cache hit as plain readings;
// Detailed exposes counts and totals with time and token-usage dialogs.
// Settled-node identity prevents stream-delta updates from rerendering the row.
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
// Composer statistics pills, each its own 'conversation.composer.dock' list
// entry (activity, usage) so a plugin can replace or add one pill by id.
// Compact keeps speed and cache hit as plain readings; Detailed adds counts,
// token totals, and click-open dialogs. Settled-node identity prevents
// stream-delta updates from rerendering the pills.
import { memo, useMemo, useState } from 'react'
import { memo, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -119,112 +119,110 @@ export function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsPillsProps extends InjectFace<PerformanceUsageInjected> {
type Translate = ChatViewSlotProps['t']
/** Props shared by every composer stats pill: the conversation-snapshot selector plus the projection read seat. */
export interface StatPillProps extends InjectFace<PerformanceUsageInjected> {
useChat: SnapshotSelectorHook<ChatSnapshot>
useProjection: UseProjection
/** The owning dock's locale seat. */
t: ChatViewSlotProps['t']
t: Translate
}
function exactCount(value: number, t: ChatViewSlotProps['t']): string {
/** Dock entry id carried as `data-composer-stat` on each pill. */
type StatId = 'activity' | 'usage'
interface PillContent {
stat: StatId
icon: ReactNode
label: ReactNode
}
function exactCount(value: number, t: Translate): string {
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
}
/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
type PillDialog = Pick<ReturnType<typeof useStatDialog>, 'open' | 'setOpen'>
function joined(first: string, second: string | null): ReactNode {
if (second === null) return first
return (
<>
{first}
<span className={css.sep} aria-hidden>·</span>
{second}
</>
)
}
function TimePill({ stats, t, dialog }: {
stats: WindowStats
t: ChatViewSlotProps['t']
dialog: PillDialog
}) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
const tps = stats.decodeMs > 0
? t('message.tokensPerSecond', {
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
})
: null
const label = (
<span className={css.label}>
{counts}
{tps !== null && (
<>
<span className={css.sep} aria-hidden>·</span>
{tps}
</>
)}
// Every figure rides the durable sessionStats projection, so paging and
// compaction cannot change any of them; an assembly without the unit falls
// back to the window-scoped fold wholesale (same field names), paid only
// while no projection value is served.
function useSessionStats(useChat: StatPillProps['useChat'], useProjection: UseProjection): WindowStats {
const settledNodes = useChat(s => s.legacy.nodes)
const projected = useProjection('sessionStats')
return useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
}
function decodeSpeed(stats: WindowStats, t: Translate): string {
return t('message.tokensPerSecond', {
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
})
}
/** A static reading: used when the pill has no dialog rows or the mode is Compact. */
function PlainPill({ stat, icon, label }: PillContent) {
return (
<span className={css.anchor} data-composer-stat={stat}>
<span className={css.pill}>
{icon}
<span className={css.label}>{label}</span>
</span>
</span>
)
// A window without one timed figure has no dialog rows to show, so the pill
// stays a plain reading instead of a button opening an empty dialog.
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
return (
<span className={css.anchor}>
<span className={css.pill}>
<IconGaugeOutlineRegular />
{label}
</span>
</span>
)
}
}
/**
* A pill button opening its own portaled dialog. Each pill owns its open
* state; useStatDialog closes it on an outside pointerdown or click, so at
* most one dialog is open across the dock.
*/
function DialogPill({ stat, icon, label, ariaLabel, title, titleValue, children }: PillContent & {
ariaLabel?: string
title: string
titleValue?: string
children: ReactNode
}) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
return (
<span ref={rootRef} className={css.anchor}>
<span ref={rootRef} className={css.anchor} data-composer-stat={stat}>
<button
type="button"
className={css.pill}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={tps === null ? counts : `${counts} · ${tps}`}
aria-label={ariaLabel}
onClick={() => { setOpen(!open) }}
>
<IconGaugeOutlineRegular />
{label}
{icon}
<span className={css.label}>{label}</span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={dialogCss.panel}
role="dialog"
aria-label={t('stats.dialog.title')}
aria-label={title}
style={pos ?? MEASURE_STYLE}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconGaugeOutlineRegular />
{t('stats.dialog.title')}
{icon}
{title}
</span>
{titleValue !== undefined && <span className={dialogCss.titleValue}>{titleValue}</span>}
</div>
<div className={dialogCss.titleRule} aria-hidden />
<dl className={dialogCss.details} data-session-stats-details>
{stats.llmMs > 0 && (
<>
<dt>{t('stats.dialog.llmTime')}</dt>
<dd>{formatDuration(stats.llmMs, t)}</dd>
</>
)}
{stats.toolMs > 0 && (
<>
<dt>{t('stats.dialog.toolTime')}</dt>
<dd>{formatDuration(stats.toolMs, t)}</dd>
</>
)}
{stats.ttftSteps > 0 && (
<>
<dt>{t('stats.dialog.ttft')}</dt>
<dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
</>
)}
{stats.decodeMs > 0 && (
<>
<dt>{t('stats.dialog.speed')}</dt>
<dd>{t('message.tokensPerSecond', {
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
})}</dd>
</>
)}
</dl>
{children}
</div>,
document.body,
)}
@@ -232,141 +230,105 @@ function TimePill({ stats, t, dialog }: {
)
}
function UsagePill({ usage, t, dialog }: {
usage: TokenUsageProjection
t: ChatViewSlotProps['t']
dialog: PillDialog
}) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
/**
* Turn and step counts with whole-session speed, opening the time and speed
* dialog; Compact keeps only the speed reading.
*/
export const ActivityPill = memo(function ActivityPill({ useChat, useProjection, usePerformanceUsage, t }: StatPillProps) {
const mode = usePerformanceUsage(value => value)
const stats = useSessionStats(useChat, useProjection)
const speed = stats.decodeMs > 0 ? decodeSpeed(stats, t) : null
const icon = <IconGaugeOutlineRegular />
if (mode === 'compact') return speed === null ? null : <PlainPill stat="activity" icon={icon} label={speed} />
if (stats.steps === 0) return null
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
const content: PillContent = { stat: 'activity', icon, label: joined(counts, speed) }
// A window without one timed figure has no dialog rows to show, so the pill
// stays a plain reading instead of a button opening an empty dialog.
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && speed === null) return <PlainPill {...content} />
return (
<DialogPill {...content} ariaLabel={speed === null ? counts : `${counts} · ${speed}`} title={t('stats.dialog.title')}>
<dl className={dialogCss.details} data-session-stats-details>
{stats.llmMs > 0 && (
<>
<dt>{t('stats.dialog.llmTime')}</dt>
<dd>{formatDuration(stats.llmMs, t)}</dd>
</>
)}
{stats.toolMs > 0 && (
<>
<dt>{t('stats.dialog.toolTime')}</dt>
<dd>{formatDuration(stats.toolMs, t)}</dd>
</>
)}
{stats.ttftSteps > 0 && (
<>
<dt>{t('stats.dialog.ttft')}</dt>
<dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
</>
)}
{speed !== null && (
<>
<dt>{t('stats.dialog.speed')}</dt>
<dd>{speed}</dd>
</>
)}
</dl>
</DialogPill>
)
})
/** Whole-log token total and cache hit; Compact keeps only the cache hit. */
export const UsagePill = memo(function UsagePill({ useProjection, usePerformanceUsage, t }: StatPillProps) {
const mode = usePerformanceUsage(value => value)
const usage = useProjection('tokenUsage')
// Gated on actual token activity: a session whose steps all settled without
// billing (e.g. every request failed) shows no usage pill.
if (usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0)) return null
const cacheHit = cacheHitPercent(usage)
const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
const icon = <IconDatabaseOutlineRegular />
if (mode === 'compact') {
return cacheHitText === null ? null : <PlainPill stat="usage" icon={icon} label={cacheHitText} />
}
// Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
const total = billedInputTokens(usage) + usage.outputTokens
const totalText = t('message.turnUsage.count', { count: formatTokens(total, t) })
const cacheHit = cacheHitPercent(usage)
const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
return (
<span ref={rootRef} className={css.anchor}>
<button
type="button"
className={css.pill}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
onClick={() => { setOpen(!open) }}
>
<IconDatabaseOutlineRegular />
<span className={css.label}>
{totalText}
{cacheHitText !== null && (
<>
<span className={css.sep} aria-hidden>·</span>
{cacheHitText}
</>
)}
</span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={dialogCss.panel}
role="dialog"
aria-label={t('stats.dialog.usageTitle')}
style={pos ?? MEASURE_STYLE}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconDatabaseOutlineRegular />
{t('stats.dialog.usageTitle')}
</span>
<span className={dialogCss.titleValue}>{exactCount(total, t)}</span>
</div>
<div className={dialogCss.titleRule} aria-hidden />
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
TurnUsagePanel's per-turn dl: same skin, different data contract (the
buckets are always present here; per-turn fields are optional). A
session that never wrote cache drops the row, as the per-turn panel
drops its absent fields. */}
<dl className={dialogCss.details} data-session-stats-usage>
{cacheHit !== null && (
<>
<dt>{t('message.turnUsage.cacheHit')}</dt>
<dd>{`${cacheHit}%`}</dd>
</>
)}
<dt>{t('message.turnUsage.input')}</dt>
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
<dt>{t('message.turnUsage.cacheRead')}</dt>
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
{usage.cacheWriteTokens !== 0 && (
<>
<dt>{t('message.turnUsage.cacheWrite')}</dt>
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
</>
)}
<dt>{t('message.turnUsage.output')}</dt>
<dd>{exactCount(usage.outputTokens, t)}</dd>
</dl>
{/* jscpd:ignore-end */}
</div>,
document.body,
)}
</span>
)
}
export const StatsPills = memo(function StatsPills({ useChat, useProjection, usePerformanceUsage, t }: StatsPillsProps) {
const mode = usePerformanceUsage(value => value)
const settledNodes = useChat(s => s.legacy.nodes)
const usage = useProjection('tokenUsage')
// One exclusive slot for both dialogs: opening either pill closes the other.
const [openPill, setOpenPill] = useState<'time' | 'usage' | null>(null)
// Every figure rides the durable sessionStats projection, so paging and
// compaction cannot change any of them; an assembly without the unit falls
// back to the window-scoped fold wholesale (same field names), paid only
// while no projection value is served.
const projected = useProjection('sessionStats')
const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
// Gated on actual token activity: a session whose steps all settled without
// billing (e.g. every request failed) shows its counts without a usage pill.
const hasTokens = usage !== undefined
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
if (mode === 'compact') {
const speed = stats.decodeMs > 0
? t('message.tokensPerSecond', { tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)) })
: null
const cacheHit = hasTokens ? cacheHitPercent(usage) : null
if (speed === null && cacheHit === null) return null
return (
<div className={css.root} data-composer-stats>
{speed !== null && <span className={css.pill}><IconGaugeOutlineRegular />{speed}</span>}
<DialogPill
stat="usage"
icon={icon}
label={joined(totalText, cacheHitText)}
ariaLabel={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
title={t('stats.dialog.usageTitle')}
titleValue={exactCount(total, t)}
>
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
TurnUsagePanel's per-turn dl: same skin, different data contract (the
buckets are always present here; per-turn fields are optional). A
session that never wrote cache drops the row, as the per-turn panel
drops its absent fields. */}
<dl className={dialogCss.details} data-session-stats-usage>
{cacheHit !== null && (
<span className={css.pill}><IconDatabaseOutlineRegular />{t('stats.cacheHit', { percent: cacheHit })}</span>
<>
<dt>{t('message.turnUsage.cacheHit')}</dt>
<dd>{`${cacheHit}%`}</dd>
</>
)}
</div>
)
}
if (stats.steps === 0 && !hasTokens) return null
return (
<div className={css.root} data-composer-stats>
{stats.steps > 0 && (
<TimePill
stats={stats}
t={t}
dialog={{
open: openPill === 'time',
setOpen: (open) => { setOpenPill(open ? 'time' : null) },
}}
/>
)}
{hasTokens && (
<UsagePill
usage={usage}
t={t}
dialog={{
open: openPill === 'usage',
setOpen: (open) => { setOpenPill(open ? 'usage' : null) },
}}
/>
)}
</div>
<dt>{t('message.turnUsage.input')}</dt>
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
<dt>{t('message.turnUsage.cacheRead')}</dt>
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
{usage.cacheWriteTokens !== 0 && (
<>
<dt>{t('message.turnUsage.cacheWrite')}</dt>
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
</>
)}
<dt>{t('message.turnUsage.output')}</dt>
<dd>{exactCount(usage.outputTokens, t)}</dd>
</dl>
{/* jscpd:ignore-end */}
</DialogPill>
)
})
@@ -1,5 +1,5 @@
/* Stat-dialog surface skin shared by the Turn-stat pills (TurnUsagePanel) and
the session stats pills (StatsPills): menu surface, elevation-prominent
the session stats pills (StatsPills.tsx): menu surface, elevation-prominent
— ContextMeter's panel skin. Placement comes from useStatDialog
(stat-dialog.ts). */
@@ -1,5 +1,5 @@
// One trigger-anchored stat dialog seat shared by the Turn-stat pills
// (TurnUsagePanel) and the composer-dock session stats pills (StatsPills);
// (TurnUsagePanel) and the composer-dock session stats pills (StatsPills.tsx);
// the matching dialog surface skin lives in stat-dialog.module.css.
import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
@@ -28,14 +28,10 @@ export interface StatDialogSeat {
/**
* One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close.
* @param controlled - external open state; when given the seat reads and writes
* it instead of owning its own, letting sibling dialogs share one exclusive slot.
* @returns the seat; spread `pos ?? MEASURE_STYLE` onto the portaled panel.
*/
export function useStatDialog(controlled?: Pick<StatDialogSeat, 'open' | 'setOpen'>): StatDialogSeat {
const [ownOpen, setOwnOpen] = useState(false)
const open = controlled?.open ?? ownOpen
const setOpen = controlled?.setOpen ?? setOwnOpen
export function useStatDialog(): StatDialogSeat {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
@@ -51,15 +47,24 @@ export function useStatDialog(controlled?: Pick<StatDialogSeat, 'open' | 'setOpe
})
// Outside pointerdown closes through the shared primitive; the portaled
// panel counts as inside. Escape close stays local, one listener while open.
// panel counts as inside. An outside click also closes, so keyboard
// activation of a sibling pill never stacks a second dialog. Escape closes.
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
const onClick = (e: MouseEvent): void => {
const target = e.target as Node
if (rootRef.current?.contains(target) !== true && panelRef.current?.contains(target) !== true) setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
document.addEventListener('click', onClick, true)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('click', onClick, true)
}
}, [open, setOpen])
return { open, setOpen, rootRef, panelRef, pos }
@@ -92,7 +92,7 @@ export interface ChatTurnProcessPresentation {
readonly compactAnswer: boolean
}
/** Compatibility projection backing StatsPills and the legacy top-level snapshot fields. */
/** Compatibility projection backing the composer stats pills (StatsPills.tsx) and the legacy top-level snapshot fields. */
export interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
@@ -826,8 +826,8 @@ const EMPTY_CONTRIBUTION: LegacyContribution = {
function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
const node = raw as ChatNode
// Content-free settled Assistants remain in the finalized compatibility
// stream so StatsPills preserves its pre-assembly step counts; hidden running
// attempts have no final Node to contribute.
// stream so the composer stats pills keep their pre-assembly step counts;
// hidden running attempts have no final Node to contribute.
if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION
switch (node.kind) {
case 'user':
@@ -898,7 +898,7 @@ function sameContribution(left: LegacyContribution | undefined, right: LegacyCon
&& sameReferences(left.nodes, right.nodes)
}
/** Incremental compatibility projection for StatsPills and legacy top-level snapshot fields. */
/** Incremental compatibility projection for the composer stats pills and legacy top-level snapshot fields. */
class LegacySliceBuilder {
private readonly contributions = new Map<string, LegacyContribution>()
private readonly finalizedContributions = new Map<string, LegacyContribution>()
@@ -26,6 +26,7 @@ import type {
import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { PerformanceUsageRowInjected } from '../src/client/settings/PerformanceUsageRow.tsx'
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
import { ActivityPill, UsagePill } from '../src/client/chat/StatsPills.tsx'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
interface ConversationTurnDataMap {
@@ -127,12 +128,28 @@ describe('Chat apply wiring', () => {
expect(b.runtime.slots.spec('conversation.chat.node'))
.toMatchObject({ kind: 'keyed', scope: 'session' })
expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id))
.toEqual(['stats'])
.toEqual(['activity', 'usage'])
expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
.toEqual(['transcript-view', 'link-opening', 'composer-enter', 'performance-usage'])
await b.runtime.dispose()
})
it('lets another registrant replace one composer stats pill by id', async () => {
const b = await bench()
onTestFinished(() => b.runtime.dispose())
function PluginActivity() { return null }
const dispose = b.runtime.ctx.slots.register({
name: 'conversation.composer.dock', id: 'activity', order: 0, priority: -1,
}, PluginActivity)
const winners = (): Record<string, unknown> => Object.fromEntries(
b.runtime.slots.entriesOfSlot('conversation.composer.dock')
.map((entry): [string, unknown] => [entry.options.id ?? '', entry.component]),
)
expect(winners()).toEqual({ activity: PluginActivity, usage: UsagePill })
dispose()
expect(winners()).toEqual({ activity: ActivityPill, usage: UsagePill })
})
it.each([
{ desktop: false, initial: 'detailed', choice: 'standard' },
{ desktop: true, initial: 'standard', choice: 'detailed' },
@@ -168,7 +185,7 @@ describe('Chat apply wiring', () => {
b.chatSettings.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'compact' } })
expect(face.hooks.performanceUsage.getSnapshot()).toBe('compact')
for (const entry of [
b.runtime.slots.entries('conversation.composer.dock').find(entry => entry.options.id === 'stats')!,
...b.runtime.slots.entries('conversation.composer.dock'),
b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'turn-tail')!,
]) {
const injected = (entry.inject as () => Pick<PerformanceUsageRowInjected, 'hooks'>)()
@@ -18,7 +18,7 @@ import {
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import { useDisclosure } from '../src/client/chat/use-disclosure.ts'
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
import { ActivityPill, UsagePill, type StatPillProps } from '../src/client/chat/StatsPills.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@@ -1056,7 +1056,7 @@ describe('small branch tails', () => {
expect(view.getByText('one-liner')).toBeTruthy()
})
it('StatsPills omits the cache-hit segment when no input accounting exists at all', () => {
it('composer stats omit the cache-hit segment when no input accounting exists at all', () => {
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.
const nodes = [{
@@ -1064,15 +1064,19 @@ describe('small branch tails', () => {
}] as const
const snap = chatSnapshotFixture({ nodes })
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const pillProps: StatPillProps = {
usePerformanceUsage: selector => selector('detailed'),
t,
useChat: bindSnapshotSelector(source),
useProjection: (key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined,
}
const view = render(
<StatsPills
usePerformanceUsage={selector => selector('detailed')}
t={t}
useChat={bindSnapshotSelector(source)}
useProjection={(key: string) => key === 'tokenUsage'
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
: undefined}
/>,
<>
<ActivityPill {...pillProps} />
<UsagePill {...pillProps} />
</>,
)
// The untimed counts pill renders static, so the usage pill is the only button.
const [usagePill] = [...view.getAllByRole('button')] as [HTMLElement]
@@ -9,13 +9,23 @@ import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-tes
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
import { StatsPills, deriveStats, formatDuration, type StatsPillsProps } from '../src/client/chat/StatsPills.tsx'
import { ActivityPill, UsagePill, deriveStats, formatDuration, type StatPillProps } from '../src/client/chat/StatsPills.tsx'
import { formatTokens } from '../src/client/chat/token-format.ts'
import { en, zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
const t: StatsPillsProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsPillsProps['t'] = makeTranslate(en, commonEn)
const t: StatPillProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatPillProps['t'] = makeTranslate(en, commonEn)
/** Both composer-dock entries in their registered order, as the dock renders them. */
function StatsPills(props: StatPillProps) {
return (
<>
<ActivityPill {...props} />
<UsagePill {...props} />
</>
)
}
afterEach(() => {
cleanup()
@@ -128,7 +138,7 @@ describe('formatters', () => {
})
})
describe('StatsPills', () => {
describe('composer stats pills', () => {
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
/** A whole-log sessionStats value: zeros plus overrides. */
@@ -140,14 +150,14 @@ describe('StatsPills', () => {
}
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): StatsPillsProps['useProjection'] {
function projections(values: Record<string, unknown>): StatPillProps['useProjection'] {
return (key: string) => values[key]
}
function props(
source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsPillsProps {
): StatPillProps {
return { usePerformanceUsage: selector => selector('detailed'), useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
@@ -233,6 +243,16 @@ describe('StatsPills', () => {
expect(timePill.getAttribute('aria-label')).toBe('1 turns 1 steps · 20 tok/s')
})
it('marks each pill with its dock entry id', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const pills = [...view.container.querySelectorAll('[data-composer-stat]')]
expect(pills.map(pill => [pill.getAttribute('data-composer-stat'), pill.textContent])).toEqual([
['activity', '1 turns 1 steps·20 tok/s'],
['usage', '105 tok·Cache hit 90%'],
])
})
it('click-opens the time-and-speed dialog carrying the time split and speeds', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
@@ -310,6 +330,8 @@ describe('StatsPills', () => {
fireEvent.click(timePill)
expect(view.getByRole('dialog').getAttribute('aria-label')).toBe('Session statistics')
// A sibling pill's pointerdown lands outside the open dialog and closes it.
fireEvent.pointerDown(usagePill)
fireEvent.click(usagePill)
const dialogs = view.getAllByRole('dialog')
expect(dialogs).toHaveLength(1)
@@ -318,6 +340,21 @@ describe('StatsPills', () => {
expect(usagePill.getAttribute('aria-expanded')).toBe('true')
})
it('swaps dialogs on keyboard activation of a sibling pill, which fires click without pointerdown', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
fireEvent.click(timePill)
// A click inside the panel keeps it open; focus moving away does not close it.
fireEvent.click(view.getByRole('dialog'))
fireEvent.focusIn(usagePill)
expect(view.getByRole('dialog').getAttribute('aria-label')).toBe('Session statistics')
fireEvent.click(usagePill)
const dialogs = view.getAllByRole('dialog')
expect(dialogs).toHaveLength(1)
expect(dialogs[0]!.getAttribute('aria-label')).toBe('Token usage')
})
it('takes every pill and dialog label from the active locale', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
@@ -457,7 +494,7 @@ describe('StatsPills', () => {
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
function Counting(p: StatsPillsProps) {
function Counting(p: StatPillProps) {
renders += 1
return <StatsPills {...p} />
}
@@ -7,7 +7,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { useDetailedPresentation } from './presentation-fixture.client.ts'
import { useDisclosure } from '../src/client/chat/use-disclosure.ts'
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
import { ActivityPill, UsagePill, type StatPillProps } from '../src/client/chat/StatsPills.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@@ -35,7 +35,7 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsPills falls back to window-node counts and drops the usage pill without projections', () => {
it('composer stats fall back to window-node counts and drop the usage pill without projections', () => {
// No sessionStats key → the window fold supplies the counts (the
// assembly-without-the-unit fallback). Node `usage` is deliberately
// ignored: billing rides the durable tokenUsage projection, so an absent
@@ -47,13 +47,17 @@ describe('render branch tails', () => {
] as const
const snap = chatSnapshotFixture({ nodes })
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const pillProps: StatPillProps = {
usePerformanceUsage: selector => selector('detailed'),
t,
useChat: bindSnapshotSelector(source),
useProjection: () => undefined,
}
const view = render(
<StatsPills
usePerformanceUsage={selector => selector('detailed')}
t={t}
useChat={bindSnapshotSelector(source)}
useProjection={() => undefined}
/>,
<>
<ActivityPill {...pillProps} />
<UsagePill {...pillProps} />
</>,
)
expect(view.container.textContent).toBe('2 轮 3 步')
// Window-fold counts carry no timed figure, so the pill is a static reading.
@@ -496,7 +496,7 @@ export const InputBar = memo(function InputBar({
</div>
</div>
</div>
<div className={css.dock}>
<div className={css.dock} data-composer-dock>
{variant === 'composer' && input !== undefined && sessionId !== undefined
? renderSlot('conversation.composer.dock', {})
: null}
@@ -500,7 +500,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-chat StatsPills id \'stats\'',
'client-ui-chat ActivityPill id \'activity\'',
'client-ui-chat UsagePill id \'usage\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',