From 21900828662167677eef493486394f7f1f564e40 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 1 Oct 2026 17:47:30 +0800 Subject: [PATCH 1/3] 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. --- apps/web/tests/message-actions.e2e.ts | 11 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 2 + packages/client/ui-chat/README.zh.md | 2 + packages/client/ui-chat/src/client/apply.ts | 17 +- .../src/client/chat/StatsPills.module.css | 22 +- .../ui-chat/src/client/chat/StatsPills.tsx | 382 ++++++++---------- .../src/client/chat/stat-dialog.module.css | 2 +- .../ui-chat/src/client/chat/stat-dialog.ts | 23 +- .../ui-chat/src/client/contract/snapshot.ts | 2 +- .../chat-snapshot-builder.ts | 6 +- .../ui-chat/tests/chat-apply.client.spec.tsx | 21 +- .../tests/chat-branch-tails.client.spec.tsx | 24 +- .../ui-chat/tests/chat-stats.client.spec.tsx | 51 ++- .../tests/gate-branch-tails.client.spec.tsx | 20 +- .../src/client/skeleton/InputBar.tsx | 2 +- .../src/client/slot-catalog.ts | 3 +- 17 files changed, 313 insertions(+), 281 deletions(-) diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 2cc13d7f85..c8b9c35b55 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -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 => (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') diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 869a54318b..a8b06e99c6 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 0740f79bb1..c3a4612411 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 98815bdc66..990f04292b 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -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 已接受的设置后会同步当前值。 偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。 diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index e5807726ac..e83c851c3b 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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)) diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.module.css b/packages/client/ui-chat/src/client/chat/StatsPills.module.css index 098b2bf609..44dab51430 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.module.css +++ b/packages/client/ui-chat/src/client/chat/StatsPills.module.css @@ -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 { diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index e7ba095dd9..498ffaa24b 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -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 { +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 { useChat: SnapshotSelectorHook 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, 'open' | 'setOpen'> +function joined(first: string, second: string | null): ReactNode { + if (second === null) return first + return ( + <> + {first} + · + {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 = ( - - {counts} - {tps !== null && ( - <> - · - {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 ( + + + {icon} + {label} + ) - // 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 ( - - - - {label} - - - ) - } +} + +/** + * 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 ( - + {open && createPortal(
- - {t('stats.dialog.title')} + {icon} + {title} + {titleValue !== undefined && {titleValue}}
-
- {stats.llmMs > 0 && ( - <> -
{t('stats.dialog.llmTime')}
-
{formatDuration(stats.llmMs, t)}
- - )} - {stats.toolMs > 0 && ( - <> -
{t('stats.dialog.toolTime')}
-
{formatDuration(stats.toolMs, t)}
- - )} - {stats.ttftSteps > 0 && ( - <> -
{t('stats.dialog.ttft')}
-
{formatDuration(stats.ttftMs / stats.ttftSteps, t)}
- - )} - {stats.decodeMs > 0 && ( - <> -
{t('stats.dialog.speed')}
-
{t('message.tokensPerSecond', { - tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)), - })}
- - )} -
+ {children}
, 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 = + if (mode === 'compact') return speed === null ? null : + 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 + return ( + +
+ {stats.llmMs > 0 && ( + <> +
{t('stats.dialog.llmTime')}
+
{formatDuration(stats.llmMs, t)}
+ + )} + {stats.toolMs > 0 && ( + <> +
{t('stats.dialog.toolTime')}
+
{formatDuration(stats.toolMs, t)}
+ + )} + {stats.ttftSteps > 0 && ( + <> +
{t('stats.dialog.ttft')}
+
{formatDuration(stats.ttftMs / stats.ttftSteps, t)}
+ + )} + {speed !== null && ( + <> +
{t('stats.dialog.speed')}
+
{speed}
+ + )} +
+
+ ) +}) + +/** 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 = + if (mode === 'compact') { + return cacheHitText === null ? null : + } // 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 ( - - - {open && createPortal( -
-
- - - {t('stats.dialog.usageTitle')} - - {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. */} -
- {cacheHit !== null && ( - <> -
{t('message.turnUsage.cacheHit')}
-
{`${cacheHit}%`}
- - )} -
{t('message.turnUsage.input')}
-
{exactCount(usage.uncachedInputTokens, t)}
-
{t('message.turnUsage.cacheRead')}
-
{exactCount(usage.cacheReadTokens, t)}
- {usage.cacheWriteTokens !== 0 && ( - <> -
{t('message.turnUsage.cacheWrite')}
-
{exactCount(usage.cacheWriteTokens, t)}
- - )} -
{t('message.turnUsage.output')}
-
{exactCount(usage.outputTokens, t)}
-
- {/* jscpd:ignore-end */} -
, - document.body, - )} - - ) -} - -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 ( -
- {speed !== null && {speed}} + + {/* 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. */} +
{cacheHit !== null && ( - {t('stats.cacheHit', { percent: cacheHit })} + <> +
{t('message.turnUsage.cacheHit')}
+
{`${cacheHit}%`}
+ )} -
- ) - } - if (stats.steps === 0 && !hasTokens) return null - return ( -
- {stats.steps > 0 && ( - { setOpenPill(open ? 'time' : null) }, - }} - /> - )} - {hasTokens && ( - { setOpenPill(open ? 'usage' : null) }, - }} - /> - )} -
+
{t('message.turnUsage.input')}
+
{exactCount(usage.uncachedInputTokens, t)}
+
{t('message.turnUsage.cacheRead')}
+
{exactCount(usage.cacheReadTokens, t)}
+ {usage.cacheWriteTokens !== 0 && ( + <> +
{t('message.turnUsage.cacheWrite')}
+
{exactCount(usage.cacheWriteTokens, t)}
+ + )} +
{t('message.turnUsage.output')}
+
{exactCount(usage.outputTokens, t)}
+ + {/* jscpd:ignore-end */} + ) }) diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css index 619fc08ceb..4c5de52c51 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css @@ -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). */ diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.ts b/packages/client/ui-chat/src/client/chat/stat-dialog.ts index 10c1071d3c..ce1cbae2df 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.ts +++ b/packages/client/ui-chat/src/client/chat/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 { - 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(null) const panelRef = useRef(null) @@ -51,15 +47,24 @@ export function useStatDialog(controlled?: Pick { 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 } diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index 5a9b03dea0..e979f835b6 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -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 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 0cec87d246..2f76f64a4c 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -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() private readonly finalizedContributions = new Map() diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index fbd78cab31..d4bb927e4f 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -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 => 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)() diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index 5880bc5792..7d6befbe02 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -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( - selector('detailed')} - t={t} - useChat={bindSnapshotSelector(source)} - useProjection={(key: string) => key === 'tokenUsage' - ? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 } - : undefined} - />, + <> + + + , ) // The untimed counts pill renders static, so the usage pill is the only button. const [usagePill] = [...view.getAllByRole('button')] as [HTMLElement] diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index c24e93bd5c..aec58b704e 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -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 ( + <> + + + + ) +} 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): StatsPillsProps['useProjection'] { + function projections(values: Record): StatPillProps['useProjection'] { return (key: string) => values[key] } function props( source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void }, values: Record = { 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() + 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() @@ -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() + 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() @@ -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 } diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index f027c87b0b..2ae9fe6621 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -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( - selector('detailed')} - t={t} - useChat={bindSnapshotSelector(source)} - useProjection={() => undefined} - />, + <> + + + , ) expect(view.container.textContent).toBe('2 轮 3 步') // Window-fold counts carry no timed figure, so the pill is a static reading. diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 2361d96c99..031413132b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -496,7 +496,7 @@ export const InputBar = memo(function InputBar({
-
+
{variant === 'composer' && input !== undefined && sessionId !== undefined ? renderSlot('conversation.composer.dock', {}) : null} diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 24deccd10c..e7cbf3ad21 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -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}', From 1f8cdc08a1d83dac2ab345725a0418325b48d771 Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 1 Oct 2026 19:06:32 +0800 Subject: [PATCH 2/3] fix(ui-chat): document dock pill replacement order and outside-click close --- packages/client/ui-chat/README.i18n.yaml | 4 ++-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- .../client/ui-chat/src/client/chat/StatsPills.tsx | 2 +- .../client/ui-chat/src/client/chat/stat-dialog.ts | 13 +++++++++---- .../ui-chat/tests/turn-usage-panel.client.spec.tsx | 9 +++++++++ 6 files changed, 23 insertions(+), 9 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index a8b06e99c6..cfba3cf56d 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2157fcd6b0d100b5 zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: ad7fc783975fb3a4 - zh: 5a17e5369cd7b328 + en: a9a032b286071d96 + zh: 899cb3273bf1350b /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index c3a4612411..9f4896a1a2 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -60,7 +60,7 @@ 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. +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 and the same `order`; the dock orders rows by `order`, while the lower `priority` that a dynamic plugin receives automatically picks the replacement. A new id adds a pill. Each pill carries its id in `data-composer-stat`. An open pill dialog closes on Escape or on a pointerdown or click outside it, so activating another pill by pointer or keyboard swaps dialogs instead of stacking them. 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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 990f04292b..a76db0619f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -60,7 +60,7 @@ 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。指针或焦点移到已打开的对话框之外时,该对话框会关闭,因此同时最多打开一个对话框。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个胶囊;dock 按 `order` 排列各行,动态插件自动获得的更低 `priority` 决定由替换者渲染。以新 id 注册则新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个胶囊会切换对话框,而不会叠加两个。 在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 498ffaa24b..13db96f50a 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -187,7 +187,7 @@ function PlainPill({ stat, icon, label }: PillContent) { * most one dialog is open across the dock. */ function DialogPill({ stat, icon, label, ariaLabel, title, titleValue, children }: PillContent & { - ariaLabel?: string + ariaLabel: string title: string titleValue?: string children: ReactNode diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.ts b/packages/client/ui-chat/src/client/chat/stat-dialog.ts index ce1cbae2df..43ee6cd220 100644 --- a/packages/client/ui-chat/src/client/chat/stat-dialog.ts +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.ts @@ -47,8 +47,10 @@ export function useStatDialog(): StatDialogSeat { }) // Outside pointerdown closes through the shared primitive; the portaled - // panel counts as inside. An outside click also closes, so keyboard - // activation of a sibling pill never stacks a second dialog. Escape closes. + // panel counts as inside. An outside click also closes, because keyboard + // activation of a sibling trigger fires click without pointerdown; the + // capture phase lets that click close this dialog before the sibling opens. + // Escape closes. useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef) useEffect(() => { if (!open) return @@ -56,8 +58,11 @@ export function useStatDialog(): StatDialogSeat { 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) + if (e.target instanceof Node + && rootRef.current?.contains(e.target) !== true + && panelRef.current?.contains(e.target) !== true) { + setOpen(false) + } } document.addEventListener('keydown', onKeyDown) document.addEventListener('click', onClick, true) 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 107ca55f9e..31d11413b7 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,4 +95,13 @@ describe('TurnUsagePanel', () => { fireEvent.pointerDown(document.body) expect(view.queryByRole('dialog')).toBeNull() }) + + it('closes on an outside click without pointerdown, as keyboard activation elsewhere fires', () => { + const view = render() + fireEvent.click(view.getByRole('button')) + fireEvent.click(view.getByRole('dialog')) + expect(view.queryByRole('dialog')).toBeTruthy() + fireEvent.click(document.body) + expect(view.queryByRole('dialog')).toBeNull() + }) }) From 68ead9fcedbfc30af6c5df9f9787480ff8db1e0d Mon Sep 17 00:00:00 2001 From: turtle1999 Date: Thu, 1 Oct 2026 19:50:18 +0800 Subject: [PATCH 3/3] docs(ui-chat): state static plugin priority and Escape close --- packages/client/ui-chat/README.i18n.yaml | 4 ++-- packages/client/ui-chat/README.md | 2 +- packages/client/ui-chat/README.zh.md | 2 +- packages/client/ui-chat/src/client/chat/StatsPills.tsx | 4 ++-- packages/client/ui-chat/tests/chat-stats.client.spec.tsx | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index cfba3cf56d..b068e96a84 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -24,8 +24,8 @@ en: 2157fcd6b0d100b5 zh: 9e865ac3953ebe30 /deepseek-ai-dsh-client-ui-chat/turn-token-usage: - en: a9a032b286071d96 - zh: 899cb3273bf1350b + en: 14343dd14e493f4b + zh: 7cc7a25d05ea798f /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: en: b4cd36e7ab91281e zh: 8ccf412b9dde61d5 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 9f4896a1a2..b18dcc1e98 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -60,7 +60,7 @@ 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 and the same `order`; the dock orders rows by `order`, while the lower `priority` that a dynamic plugin receives automatically picks the replacement. A new id adds a pill. Each pill carries its id in `data-composer-stat`. An open pill dialog closes on Escape or on a pointerdown or click outside it, so activating another pill by pointer or keyboard swaps dialogs instead of stacking them. +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 and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a pill. Each pill carries its id in `data-composer-stat`. An open pill dialog closes on Escape or on a pointerdown or click outside it, so activating another pill by pointer or keyboard swaps dialogs instead of stacking them. 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. diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index a76db0619f..b1dc78f5bd 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -60,7 +60,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。 -输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个胶囊;dock 按 `order` 排列各行,动态插件自动获得的更低 `priority` 决定由替换者渲染。以新 id 注册则新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个胶囊会切换对话框,而不会叠加两个。 +输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个胶囊;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个胶囊会切换对话框,而不会叠加两个。 在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。 diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx index 13db96f50a..838effb768 100644 --- a/packages/client/ui-chat/src/client/chat/StatsPills.tsx +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -183,8 +183,8 @@ function PlainPill({ stat, icon, label }: PillContent) { /** * 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. + * state; useStatDialog closes it on Escape or 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 diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index aec58b704e..79281d39d1 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -304,7 +304,7 @@ describe('composer stats pills', () => { expect(dialog.textContent).not.toContain('LLM time') }) - it('closes the dialog on Escape or outside pointerdown', () => { + it('closes the dialog on Escape or an outside pointerdown', () => { const { source } = makeSource({ nodes: [timedStep()] }) const view = render() const timePill = view.getAllByRole('button')[0]!