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 a2163f8a30..b2435fa8ab 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: 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 f188c7ec1d..b8e1441864 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 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. 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 721f36d7ec..607b3075a4 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 总量与缓存命中)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个胶囊;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增胶囊。每个胶囊在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个胶囊会切换对话框,而不会叠加两个。 + 在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 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..838effb768 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 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 + 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..43ee6cd220 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,29 @@ export function useStatDialog(controlled?: Pick { if (!open) return const onKeyDown = (e: KeyboardEvent): void => { if (e.key === 'Escape') setOpen(false) } + const onClick = (e: MouseEvent): void => { + if (e.target instanceof Node + && rootRef.current?.contains(e.target) !== true + && panelRef.current?.contains(e.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..79281d39d1 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() @@ -284,7 +304,7 @@ describe('StatsPills', () => { 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]! @@ -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-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() + }) }) 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}',