Merge pull request #5596 from deepseek-harness/refactor/composer-dock-stat-ids
refactor(ui-chat): register composer stats pills as separate dock entries
This commit is contained in:
commit
2596f6f592
18 files changed
+328
-282
No files matched your search
@@ -330,7 +330,8 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
|
||||
it.skipIf(MODE === 'record')('persists performance detail and hides statistics in Compact', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-performance-usage'))
|
||||
const stats = page.locator('[data-composer-stats]')
|
||||
const stats = page.locator('[data-composer-stat]')
|
||||
const statsText = async (): Promise<string> => (await stats.allTextContents()).join(' ')
|
||||
await openSettings(page, 'en')
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
|
||||
const row = dialog.getByText('Performance & usage', { exact: true }).locator('../..')
|
||||
@@ -339,11 +340,11 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
await expect.poll(() => scaffold.ctx.settings.describe().find(row => row.ns === 'ui-chat')?.value).toMatchObject({ performanceUsage: 'compact' })
|
||||
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
|
||||
await expect.poll(() => stats.locator('button').count()).toBe(0)
|
||||
expect(await stats.textContent()).not.toContain('turns')
|
||||
expect(await stats.textContent()).toContain('Cache hit')
|
||||
await stats.hover()
|
||||
expect(await statsText()).not.toContain('turns')
|
||||
expect(await statsText()).toContain('Cache hit')
|
||||
await stats.first().hover()
|
||||
expect(await page.getByRole('dialog').count()).toBe(0)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-stats]', scaffold.workspaceCwd), MODE)
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-dock]', scaffold.workspaceCwd), MODE)
|
||||
const warningStart = tripwire.warnings.length
|
||||
await page.reload()
|
||||
await openSettings(page, 'en')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 已接受的设置后会同步当前值。
|
||||
|
||||
偏好菜单在发布新选择之前,先将焦点还给触发按钮,且不引起滚动。
|
||||
|
||||
@@ -29,7 +29,7 @@ import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
|
||||
import { ApprovalCommand } from './chat/ApprovalCommand.tsx'
|
||||
import { ChatView } from './chat/ChatView.tsx'
|
||||
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
|
||||
import { StatsPills } from './chat/StatsPills.tsx'
|
||||
import { ActivityPill, UsagePill } from './chat/StatsPills.tsx'
|
||||
import { registerConversationNodes } from './conversation-nodes/register.ts'
|
||||
import { QuotaNoticeHost } from './chat/QuotaNoticeHost.tsx'
|
||||
import { en, NS, zh } from './locale.ts'
|
||||
@@ -281,11 +281,16 @@ export function apply(ctx: Context): void {
|
||||
}),
|
||||
}, QuotaNoticeHost))
|
||||
|
||||
ctx.slots.inject('conversation.composer.dock', () =>
|
||||
ctx.slots.register({
|
||||
name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,
|
||||
inject: () => ({ hooks: { performanceUsage } }),
|
||||
}, StatsPills))
|
||||
// One dock entry per pill, so a plugin replaces or adds a single pill by id.
|
||||
const statPillInject = () => ({ hooks: { performanceUsage } })
|
||||
ctx.slots.inject('conversation.composer.dock', function* () {
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.composer.dock', id: 'activity', order: 0, locale: NS, inject: statPillInject,
|
||||
}, ActivityPill)
|
||||
yield ctx.slots.register({
|
||||
name: 'conversation.composer.dock', id: 'usage', order: 1, locale: NS, inject: statPillInject,
|
||||
}, UsagePill)
|
||||
})
|
||||
|
||||
ctx.slots.inject('conversation.approval.detail', () =>
|
||||
ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand))
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
/* Two icon pills side by side under the composer, 12/20 tertiary text tier
|
||||
with a hover-pill affordance; each pill's click-open panel wears the shared
|
||||
stat-dialog.module.css skin. */
|
||||
/* Icon pills under the composer, each one composer-dock entry laid out by the
|
||||
dock's flex row; 12/20 tertiary text tier with a hover-pill affordance; each
|
||||
pill's click-open panel wears the shared stat-dialog.module.css skin. */
|
||||
|
||||
.root {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
/* Dialog anchor: shrink-wraps its pill so the panel clamp measures the pill. */
|
||||
/* Entry root and dialog anchor: shrink-wraps its pill so the panel clamp
|
||||
measures the pill. */
|
||||
.anchor {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 1px);
|
||||
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
|
||||
}
|
||||
|
||||
.pill {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Composer statistics: Compact exposes speed and cache hit as plain readings;
|
||||
// Detailed exposes counts and totals with time and token-usage dialogs.
|
||||
// Settled-node identity prevents stream-delta updates from rerendering the row.
|
||||
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
|
||||
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
|
||||
// Composer statistics pills, each its own 'conversation.composer.dock' list
|
||||
// entry (activity, usage) so a plugin can replace or add one pill by id.
|
||||
// Compact keeps speed and cache hit as plain readings; Detailed adds counts,
|
||||
// token totals, and click-open dialogs. Settled-node identity prevents
|
||||
// stream-delta updates from rerendering the pills.
|
||||
|
||||
import { memo, useMemo, useState } from 'react'
|
||||
import { memo, useMemo, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
@@ -119,112 +119,110 @@ export function billedInputTokens(usage: TokenUsageProjection): number {
|
||||
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
|
||||
}
|
||||
|
||||
/** Props: the conversation-snapshot selector plus the projection read seat. */
|
||||
export interface StatsPillsProps extends InjectFace<PerformanceUsageInjected> {
|
||||
type Translate = ChatViewSlotProps['t']
|
||||
|
||||
/** Props shared by every composer stats pill: the conversation-snapshot selector plus the projection read seat. */
|
||||
export interface StatPillProps extends InjectFace<PerformanceUsageInjected> {
|
||||
useChat: SnapshotSelectorHook<ChatSnapshot>
|
||||
useProjection: UseProjection
|
||||
/** The owning dock's locale seat. */
|
||||
t: ChatViewSlotProps['t']
|
||||
t: Translate
|
||||
}
|
||||
|
||||
function exactCount(value: number, t: ChatViewSlotProps['t']): string {
|
||||
/** Dock entry id carried as `data-composer-stat` on each pill. */
|
||||
type StatId = 'activity' | 'usage'
|
||||
|
||||
interface PillContent {
|
||||
stat: StatId
|
||||
icon: ReactNode
|
||||
label: ReactNode
|
||||
}
|
||||
|
||||
function exactCount(value: number, t: Translate): string {
|
||||
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
|
||||
}
|
||||
|
||||
/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
|
||||
type PillDialog = Pick<ReturnType<typeof useStatDialog>, 'open' | 'setOpen'>
|
||||
function joined(first: string, second: string | null): ReactNode {
|
||||
if (second === null) return first
|
||||
return (
|
||||
<>
|
||||
{first}
|
||||
<span className={css.sep} aria-hidden>·</span>
|
||||
{second}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TimePill({ stats, t, dialog }: {
|
||||
stats: WindowStats
|
||||
t: ChatViewSlotProps['t']
|
||||
dialog: PillDialog
|
||||
}) {
|
||||
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
||||
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
|
||||
const tps = stats.decodeMs > 0
|
||||
? t('message.tokensPerSecond', {
|
||||
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
||||
})
|
||||
: null
|
||||
const label = (
|
||||
<span className={css.label}>
|
||||
{counts}
|
||||
{tps !== null && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden>·</span>
|
||||
{tps}
|
||||
</>
|
||||
)}
|
||||
// Every figure rides the durable sessionStats projection, so paging and
|
||||
// compaction cannot change any of them; an assembly without the unit falls
|
||||
// back to the window-scoped fold wholesale (same field names), paid only
|
||||
// while no projection value is served.
|
||||
function useSessionStats(useChat: StatPillProps['useChat'], useProjection: UseProjection): WindowStats {
|
||||
const settledNodes = useChat(s => s.legacy.nodes)
|
||||
const projected = useProjection('sessionStats')
|
||||
return useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
|
||||
}
|
||||
|
||||
function decodeSpeed(stats: WindowStats, t: Translate): string {
|
||||
return t('message.tokensPerSecond', {
|
||||
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
||||
})
|
||||
}
|
||||
|
||||
/** A static reading: used when the pill has no dialog rows or the mode is Compact. */
|
||||
function PlainPill({ stat, icon, label }: PillContent) {
|
||||
return (
|
||||
<span className={css.anchor} data-composer-stat={stat}>
|
||||
<span className={css.pill}>
|
||||
{icon}
|
||||
<span className={css.label}>{label}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
// A window without one timed figure has no dialog rows to show, so the pill
|
||||
// stays a plain reading instead of a button opening an empty dialog.
|
||||
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
|
||||
return (
|
||||
<span className={css.anchor}>
|
||||
<span className={css.pill}>
|
||||
<IconGaugeOutlineRegular />
|
||||
{label}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A pill button opening its own portaled dialog. Each pill owns its open
|
||||
* state; useStatDialog closes it on 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 (
|
||||
<span ref={rootRef} className={css.anchor}>
|
||||
<span ref={rootRef} className={css.anchor} data-composer-stat={stat}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pill}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={tps === null ? counts : `${counts} · ${tps}`}
|
||||
aria-label={ariaLabel}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<IconGaugeOutlineRegular />
|
||||
{label}
|
||||
{icon}
|
||||
<span className={css.label}>{label}</span>
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={dialogCss.panel}
|
||||
role="dialog"
|
||||
aria-label={t('stats.dialog.title')}
|
||||
aria-label={title}
|
||||
style={pos ?? MEASURE_STYLE}
|
||||
>
|
||||
<div className={dialogCss.title}>
|
||||
<span className={dialogCss.titleLabel}>
|
||||
<IconGaugeOutlineRegular />
|
||||
{t('stats.dialog.title')}
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
{titleValue !== undefined && <span className={dialogCss.titleValue}>{titleValue}</span>}
|
||||
</div>
|
||||
<div className={dialogCss.titleRule} aria-hidden />
|
||||
<dl className={dialogCss.details} data-session-stats-details>
|
||||
{stats.llmMs > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.llmTime')}</dt>
|
||||
<dd>{formatDuration(stats.llmMs, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{stats.toolMs > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.toolTime')}</dt>
|
||||
<dd>{formatDuration(stats.toolMs, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{stats.ttftSteps > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.ttft')}</dt>
|
||||
<dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{stats.decodeMs > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.speed')}</dt>
|
||||
<dd>{t('message.tokensPerSecond', {
|
||||
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
|
||||
})}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
@@ -232,141 +230,105 @@ function TimePill({ stats, t, dialog }: {
|
||||
)
|
||||
}
|
||||
|
||||
function UsagePill({ usage, t, dialog }: {
|
||||
usage: TokenUsageProjection
|
||||
t: ChatViewSlotProps['t']
|
||||
dialog: PillDialog
|
||||
}) {
|
||||
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
|
||||
/**
|
||||
* Turn and step counts with whole-session speed, opening the time and speed
|
||||
* dialog; Compact keeps only the speed reading.
|
||||
*/
|
||||
export const ActivityPill = memo(function ActivityPill({ useChat, useProjection, usePerformanceUsage, t }: StatPillProps) {
|
||||
const mode = usePerformanceUsage(value => value)
|
||||
const stats = useSessionStats(useChat, useProjection)
|
||||
const speed = stats.decodeMs > 0 ? decodeSpeed(stats, t) : null
|
||||
const icon = <IconGaugeOutlineRegular />
|
||||
if (mode === 'compact') return speed === null ? null : <PlainPill stat="activity" icon={icon} label={speed} />
|
||||
if (stats.steps === 0) return null
|
||||
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
|
||||
const content: PillContent = { stat: 'activity', icon, label: joined(counts, speed) }
|
||||
// A window without one timed figure has no dialog rows to show, so the pill
|
||||
// stays a plain reading instead of a button opening an empty dialog.
|
||||
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && speed === null) return <PlainPill {...content} />
|
||||
return (
|
||||
<DialogPill {...content} ariaLabel={speed === null ? counts : `${counts} · ${speed}`} title={t('stats.dialog.title')}>
|
||||
<dl className={dialogCss.details} data-session-stats-details>
|
||||
{stats.llmMs > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.llmTime')}</dt>
|
||||
<dd>{formatDuration(stats.llmMs, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{stats.toolMs > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.toolTime')}</dt>
|
||||
<dd>{formatDuration(stats.toolMs, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{stats.ttftSteps > 0 && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.ttft')}</dt>
|
||||
<dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
{speed !== null && (
|
||||
<>
|
||||
<dt>{t('stats.dialog.speed')}</dt>
|
||||
<dd>{speed}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</DialogPill>
|
||||
)
|
||||
})
|
||||
|
||||
/** Whole-log token total and cache hit; Compact keeps only the cache hit. */
|
||||
export const UsagePill = memo(function UsagePill({ useProjection, usePerformanceUsage, t }: StatPillProps) {
|
||||
const mode = usePerformanceUsage(value => value)
|
||||
const usage = useProjection('tokenUsage')
|
||||
// Gated on actual token activity: a session whose steps all settled without
|
||||
// billing (e.g. every request failed) shows no usage pill.
|
||||
if (usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0)) return null
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
|
||||
const icon = <IconDatabaseOutlineRegular />
|
||||
if (mode === 'compact') {
|
||||
return cacheHitText === null ? null : <PlainPill stat="usage" icon={icon} label={cacheHitText} />
|
||||
}
|
||||
// Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
|
||||
const total = billedInputTokens(usage) + usage.outputTokens
|
||||
const totalText = t('message.turnUsage.count', { count: formatTokens(total, t) })
|
||||
const cacheHit = cacheHitPercent(usage)
|
||||
const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
|
||||
return (
|
||||
<span ref={rootRef} className={css.anchor}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.pill}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
|
||||
onClick={() => { setOpen(!open) }}
|
||||
>
|
||||
<IconDatabaseOutlineRegular />
|
||||
<span className={css.label}>
|
||||
{totalText}
|
||||
{cacheHitText !== null && (
|
||||
<>
|
||||
<span className={css.sep} aria-hidden>·</span>
|
||||
{cacheHitText}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={dialogCss.panel}
|
||||
role="dialog"
|
||||
aria-label={t('stats.dialog.usageTitle')}
|
||||
style={pos ?? MEASURE_STYLE}
|
||||
>
|
||||
<div className={dialogCss.title}>
|
||||
<span className={dialogCss.titleLabel}>
|
||||
<IconDatabaseOutlineRegular />
|
||||
{t('stats.dialog.usageTitle')}
|
||||
</span>
|
||||
<span className={dialogCss.titleValue}>{exactCount(total, t)}</span>
|
||||
</div>
|
||||
<div className={dialogCss.titleRule} aria-hidden />
|
||||
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
|
||||
TurnUsagePanel's per-turn dl: same skin, different data contract (the
|
||||
buckets are always present here; per-turn fields are optional). A
|
||||
session that never wrote cache drops the row, as the per-turn panel
|
||||
drops its absent fields. */}
|
||||
<dl className={dialogCss.details} data-session-stats-usage>
|
||||
{cacheHit !== null && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheHit')}</dt>
|
||||
<dd>{`${cacheHit}%`}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('message.turnUsage.input')}</dt>
|
||||
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
|
||||
<dt>{t('message.turnUsage.cacheRead')}</dt>
|
||||
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
|
||||
{usage.cacheWriteTokens !== 0 && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheWrite')}</dt>
|
||||
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('message.turnUsage.output')}</dt>
|
||||
<dd>{exactCount(usage.outputTokens, t)}</dd>
|
||||
</dl>
|
||||
{/* jscpd:ignore-end */}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export const StatsPills = memo(function StatsPills({ useChat, useProjection, usePerformanceUsage, t }: StatsPillsProps) {
|
||||
const mode = usePerformanceUsage(value => value)
|
||||
const settledNodes = useChat(s => s.legacy.nodes)
|
||||
const usage = useProjection('tokenUsage')
|
||||
// One exclusive slot for both dialogs: opening either pill closes the other.
|
||||
const [openPill, setOpenPill] = useState<'time' | 'usage' | null>(null)
|
||||
// Every figure rides the durable sessionStats projection, so paging and
|
||||
// compaction cannot change any of them; an assembly without the unit falls
|
||||
// back to the window-scoped fold wholesale (same field names), paid only
|
||||
// while no projection value is served.
|
||||
const projected = useProjection('sessionStats')
|
||||
const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
|
||||
// Gated on actual token activity: a session whose steps all settled without
|
||||
// billing (e.g. every request failed) shows its counts without a usage pill.
|
||||
const hasTokens = usage !== undefined
|
||||
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
|
||||
if (mode === 'compact') {
|
||||
const speed = stats.decodeMs > 0
|
||||
? t('message.tokensPerSecond', { tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)) })
|
||||
: null
|
||||
const cacheHit = hasTokens ? cacheHitPercent(usage) : null
|
||||
if (speed === null && cacheHit === null) return null
|
||||
return (
|
||||
<div className={css.root} data-composer-stats>
|
||||
{speed !== null && <span className={css.pill}><IconGaugeOutlineRegular />{speed}</span>}
|
||||
<DialogPill
|
||||
stat="usage"
|
||||
icon={icon}
|
||||
label={joined(totalText, cacheHitText)}
|
||||
ariaLabel={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
|
||||
title={t('stats.dialog.usageTitle')}
|
||||
titleValue={exactCount(total, t)}
|
||||
>
|
||||
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
|
||||
TurnUsagePanel's per-turn dl: same skin, different data contract (the
|
||||
buckets are always present here; per-turn fields are optional). A
|
||||
session that never wrote cache drops the row, as the per-turn panel
|
||||
drops its absent fields. */}
|
||||
<dl className={dialogCss.details} data-session-stats-usage>
|
||||
{cacheHit !== null && (
|
||||
<span className={css.pill}><IconDatabaseOutlineRegular />{t('stats.cacheHit', { percent: cacheHit })}</span>
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheHit')}</dt>
|
||||
<dd>{`${cacheHit}%`}</dd>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (stats.steps === 0 && !hasTokens) return null
|
||||
return (
|
||||
<div className={css.root} data-composer-stats>
|
||||
{stats.steps > 0 && (
|
||||
<TimePill
|
||||
stats={stats}
|
||||
t={t}
|
||||
dialog={{
|
||||
open: openPill === 'time',
|
||||
setOpen: (open) => { setOpenPill(open ? 'time' : null) },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{hasTokens && (
|
||||
<UsagePill
|
||||
usage={usage}
|
||||
t={t}
|
||||
dialog={{
|
||||
open: openPill === 'usage',
|
||||
setOpen: (open) => { setOpenPill(open ? 'usage' : null) },
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<dt>{t('message.turnUsage.input')}</dt>
|
||||
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
|
||||
<dt>{t('message.turnUsage.cacheRead')}</dt>
|
||||
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
|
||||
{usage.cacheWriteTokens !== 0 && (
|
||||
<>
|
||||
<dt>{t('message.turnUsage.cacheWrite')}</dt>
|
||||
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('message.turnUsage.output')}</dt>
|
||||
<dd>{exactCount(usage.outputTokens, t)}</dd>
|
||||
</dl>
|
||||
{/* jscpd:ignore-end */}
|
||||
</DialogPill>
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
/* Stat-dialog surface skin shared by the Turn-stat pills (TurnUsagePanel) and
|
||||
the session stats pills (StatsPills): menu surface, elevation-prominent
|
||||
the session stats pills (StatsPills.tsx): menu surface, elevation-prominent
|
||||
— ContextMeter's panel skin. Placement comes from useStatDialog
|
||||
(stat-dialog.ts). */
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// One trigger-anchored stat dialog seat shared by the Turn-stat pills
|
||||
// (TurnUsagePanel) and the composer-dock session stats pills (StatsPills);
|
||||
// (TurnUsagePanel) and the composer-dock session stats pills (StatsPills.tsx);
|
||||
// the matching dialog surface skin lives in stat-dialog.module.css.
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
|
||||
@@ -28,14 +28,10 @@ export interface StatDialogSeat {
|
||||
|
||||
/**
|
||||
* One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close.
|
||||
* @param controlled - external open state; when given the seat reads and writes
|
||||
* it instead of owning its own, letting sibling dialogs share one exclusive slot.
|
||||
* @returns the seat; spread `pos ?? MEASURE_STYLE` onto the portaled panel.
|
||||
*/
|
||||
export function useStatDialog(controlled?: Pick<StatDialogSeat, 'open' | 'setOpen'>): StatDialogSeat {
|
||||
const [ownOpen, setOwnOpen] = useState(false)
|
||||
const open = controlled?.open ?? ownOpen
|
||||
const setOpen = controlled?.setOpen ?? setOwnOpen
|
||||
export function useStatDialog(): StatDialogSeat {
|
||||
const [open, setOpen] = useState(false)
|
||||
const rootRef = useRef<HTMLSpanElement | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
@@ -51,15 +47,29 @@ export function useStatDialog(controlled?: Pick<StatDialogSeat, 'open' | 'setOpe
|
||||
})
|
||||
|
||||
// Outside pointerdown closes through the shared primitive; the portaled
|
||||
// panel counts as inside. Escape close stays local, one listener while open.
|
||||
// panel counts as inside. An outside click also closes, 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
|
||||
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 }
|
||||
|
||||
@@ -92,7 +92,7 @@ export interface ChatTurnProcessPresentation {
|
||||
readonly compactAnswer: boolean
|
||||
}
|
||||
|
||||
/** Compatibility projection backing StatsPills and the legacy top-level snapshot fields. */
|
||||
/** Compatibility projection backing the composer stats pills (StatsPills.tsx) and the legacy top-level snapshot fields. */
|
||||
export interface LegacyConversationSlice {
|
||||
readonly nodes: readonly ConversationNode[]
|
||||
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
|
||||
|
||||
@@ -826,8 +826,8 @@ const EMPTY_CONTRIBUTION: LegacyContribution = {
|
||||
function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
|
||||
const node = raw as ChatNode
|
||||
// Content-free settled Assistants remain in the finalized compatibility
|
||||
// stream so StatsPills preserves its pre-assembly step counts; hidden running
|
||||
// attempts have no final Node to contribute.
|
||||
// stream so the composer stats pills keep their pre-assembly step counts;
|
||||
// hidden running attempts have no final Node to contribute.
|
||||
if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION
|
||||
switch (node.kind) {
|
||||
case 'user':
|
||||
@@ -898,7 +898,7 @@ function sameContribution(left: LegacyContribution | undefined, right: LegacyCon
|
||||
&& sameReferences(left.nodes, right.nodes)
|
||||
}
|
||||
|
||||
/** Incremental compatibility projection for StatsPills and legacy top-level snapshot fields. */
|
||||
/** Incremental compatibility projection for the composer stats pills and legacy top-level snapshot fields. */
|
||||
class LegacySliceBuilder {
|
||||
private readonly contributions = new Map<string, LegacyContribution>()
|
||||
private readonly finalizedContributions = new Map<string, LegacyContribution>()
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
import type { QuotaNoticeInjected } from '@deepseek-ai/dsh-client-ui-chat/client'
|
||||
import type { PerformanceUsageRowInjected } from '../src/client/settings/PerformanceUsageRow.tsx'
|
||||
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts'
|
||||
import { ActivityPill, UsagePill } from '../src/client/chat/StatsPills.tsx'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
|
||||
interface ConversationTurnDataMap {
|
||||
@@ -127,12 +128,28 @@ describe('Chat apply wiring', () => {
|
||||
expect(b.runtime.slots.spec('conversation.chat.node'))
|
||||
.toMatchObject({ kind: 'keyed', scope: 'session' })
|
||||
expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id))
|
||||
.toEqual(['stats'])
|
||||
.toEqual(['activity', 'usage'])
|
||||
expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
|
||||
.toEqual(['transcript-view', 'link-opening', 'composer-enter', 'performance-usage'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('lets another registrant replace one composer stats pill by id', async () => {
|
||||
const b = await bench()
|
||||
onTestFinished(() => b.runtime.dispose())
|
||||
function PluginActivity() { return null }
|
||||
const dispose = b.runtime.ctx.slots.register({
|
||||
name: 'conversation.composer.dock', id: 'activity', order: 0, priority: -1,
|
||||
}, PluginActivity)
|
||||
const winners = (): Record<string, unknown> => Object.fromEntries(
|
||||
b.runtime.slots.entriesOfSlot('conversation.composer.dock')
|
||||
.map((entry): [string, unknown] => [entry.options.id ?? '', entry.component]),
|
||||
)
|
||||
expect(winners()).toEqual({ activity: PluginActivity, usage: UsagePill })
|
||||
dispose()
|
||||
expect(winners()).toEqual({ activity: ActivityPill, usage: UsagePill })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ desktop: false, initial: 'detailed', choice: 'standard' },
|
||||
{ desktop: true, initial: 'standard', choice: 'detailed' },
|
||||
@@ -168,7 +185,7 @@ describe('Chat apply wiring', () => {
|
||||
b.chatSettings.publish({ value: { linkOpening: 'sidebar', transcriptView: 'compact', performanceUsage: 'compact' } })
|
||||
expect(face.hooks.performanceUsage.getSnapshot()).toBe('compact')
|
||||
for (const entry of [
|
||||
b.runtime.slots.entries('conversation.composer.dock').find(entry => entry.options.id === 'stats')!,
|
||||
...b.runtime.slots.entries('conversation.composer.dock'),
|
||||
b.runtime.slots.entries('conversation.chat.node').find(entry => entry.options.key === 'turn-tail')!,
|
||||
]) {
|
||||
const injected = (entry.inject as () => Pick<PerformanceUsageRowInjected, 'hooks'>)()
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { useDetailedPresentation } from './presentation-fixture.client.ts'
|
||||
import { useDisclosure } from '../src/client/chat/use-disclosure.ts'
|
||||
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
|
||||
import { ActivityPill, UsagePill, type StatPillProps } from '../src/client/chat/StatsPills.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
@@ -1056,7 +1056,7 @@ describe('small branch tails', () => {
|
||||
expect(view.getByText('one-liner')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('StatsPills omits the cache-hit segment when no input accounting exists at all', () => {
|
||||
it('composer stats omit the cache-hit segment when no input accounting exists at all', () => {
|
||||
// Cache hit is null only when all three prompt buckets are zero (pure
|
||||
// output accounting) — any billed input makes it a real 0%.
|
||||
const nodes = [{
|
||||
@@ -1064,15 +1064,19 @@ describe('small branch tails', () => {
|
||||
}] as const
|
||||
const snap = chatSnapshotFixture({ nodes })
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const pillProps: StatPillProps = {
|
||||
usePerformanceUsage: selector => selector('detailed'),
|
||||
t,
|
||||
useChat: bindSnapshotSelector(source),
|
||||
useProjection: (key: string) => key === 'tokenUsage'
|
||||
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
: undefined,
|
||||
}
|
||||
const view = render(
|
||||
<StatsPills
|
||||
usePerformanceUsage={selector => selector('detailed')}
|
||||
t={t}
|
||||
useChat={bindSnapshotSelector(source)}
|
||||
useProjection={(key: string) => key === 'tokenUsage'
|
||||
? { uncachedInputTokens: 0, outputTokens: 10, cacheReadTokens: 0, cacheWriteTokens: 0 }
|
||||
: undefined}
|
||||
/>,
|
||||
<>
|
||||
<ActivityPill {...pillProps} />
|
||||
<UsagePill {...pillProps} />
|
||||
</>,
|
||||
)
|
||||
// The untimed counts pill renders static, so the usage pill is the only button.
|
||||
const [usagePill] = [...view.getAllByRole('button')] as [HTMLElement]
|
||||
|
||||
@@ -9,13 +9,23 @@ import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-tes
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import { PartialArguments } from '@deepseek-ai/dsh-util-values'
|
||||
import { StatsPills, deriveStats, formatDuration, type StatsPillsProps } from '../src/client/chat/StatsPills.tsx'
|
||||
import { ActivityPill, UsagePill, deriveStats, formatDuration, type StatPillProps } from '../src/client/chat/StatsPills.tsx'
|
||||
import { formatTokens } from '../src/client/chat/token-format.ts'
|
||||
import { en, zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
const t: StatsPillsProps['t'] = makeTranslate(zh, commonZh)
|
||||
const tEn: StatsPillsProps['t'] = makeTranslate(en, commonEn)
|
||||
const t: StatPillProps['t'] = makeTranslate(zh, commonZh)
|
||||
const tEn: StatPillProps['t'] = makeTranslate(en, commonEn)
|
||||
|
||||
/** Both composer-dock entries in their registered order, as the dock renders them. */
|
||||
function StatsPills(props: StatPillProps) {
|
||||
return (
|
||||
<>
|
||||
<ActivityPill {...props} />
|
||||
<UsagePill {...props} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -128,7 +138,7 @@ describe('formatters', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('StatsPills', () => {
|
||||
describe('composer stats pills', () => {
|
||||
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
|
||||
|
||||
/** A whole-log sessionStats value: zeros plus overrides. */
|
||||
@@ -140,14 +150,14 @@ describe('StatsPills', () => {
|
||||
}
|
||||
|
||||
/** Stub the projection seat: a key-addressed table of whole values. */
|
||||
function projections(values: Record<string, unknown>): StatsPillsProps['useProjection'] {
|
||||
function projections(values: Record<string, unknown>): StatPillProps['useProjection'] {
|
||||
return (key: string) => values[key]
|
||||
}
|
||||
|
||||
function props(
|
||||
source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void },
|
||||
values: Record<string, unknown> = { tokenUsage: USAGE },
|
||||
): StatsPillsProps {
|
||||
): StatPillProps {
|
||||
return { usePerformanceUsage: selector => selector('detailed'), useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
|
||||
}
|
||||
|
||||
@@ -233,6 +243,16 @@ describe('StatsPills', () => {
|
||||
expect(timePill.getAttribute('aria-label')).toBe('1 turns 1 steps · 20 tok/s')
|
||||
})
|
||||
|
||||
it('marks each pill with its dock entry id', () => {
|
||||
const { source } = makeSource({ nodes: [timedStep()] })
|
||||
const view = render(<StatsPills {...props(source)} />)
|
||||
const pills = [...view.container.querySelectorAll('[data-composer-stat]')]
|
||||
expect(pills.map(pill => [pill.getAttribute('data-composer-stat'), pill.textContent])).toEqual([
|
||||
['activity', '1 turns 1 steps·20 tok/s'],
|
||||
['usage', '105 tok·Cache hit 90%'],
|
||||
])
|
||||
})
|
||||
|
||||
it('click-opens the time-and-speed dialog carrying the time split and speeds', () => {
|
||||
const { source } = makeSource({ nodes: [timedStep()] })
|
||||
const view = render(<StatsPills {...props(source)} />)
|
||||
@@ -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(<StatsPills {...props(source)} />)
|
||||
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(<StatsPills {...props(source)} />)
|
||||
const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
|
||||
fireEvent.click(timePill)
|
||||
// A click inside the panel keeps it open; focus moving away does not close it.
|
||||
fireEvent.click(view.getByRole('dialog'))
|
||||
fireEvent.focusIn(usagePill)
|
||||
expect(view.getByRole('dialog').getAttribute('aria-label')).toBe('Session statistics')
|
||||
fireEvent.click(usagePill)
|
||||
const dialogs = view.getAllByRole('dialog')
|
||||
expect(dialogs).toHaveLength(1)
|
||||
expect(dialogs[0]!.getAttribute('aria-label')).toBe('Token usage')
|
||||
})
|
||||
|
||||
it('takes every pill and dialog label from the active locale', () => {
|
||||
const { source } = makeSource({ nodes: [timedStep()] })
|
||||
const view = render(<StatsPills {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
|
||||
@@ -457,7 +494,7 @@ describe('StatsPills', () => {
|
||||
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
|
||||
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
|
||||
let renders = 0
|
||||
function Counting(p: StatsPillsProps) {
|
||||
function Counting(p: StatPillProps) {
|
||||
renders += 1
|
||||
return <StatsPills {...p} />
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts
|
||||
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
|
||||
import { useDetailedPresentation } from './presentation-fixture.client.ts'
|
||||
import { useDisclosure } from '../src/client/chat/use-disclosure.ts'
|
||||
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
|
||||
import { ActivityPill, UsagePill, type StatPillProps } from '../src/client/chat/StatsPills.tsx'
|
||||
import { zh } from '../src/client/locale.ts'
|
||||
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('render branch tails', () => {
|
||||
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('StatsPills falls back to window-node counts and drops the usage pill without projections', () => {
|
||||
it('composer stats fall back to window-node counts and drop the usage pill without projections', () => {
|
||||
// No sessionStats key → the window fold supplies the counts (the
|
||||
// assembly-without-the-unit fallback). Node `usage` is deliberately
|
||||
// ignored: billing rides the durable tokenUsage projection, so an absent
|
||||
@@ -47,13 +47,17 @@ describe('render branch tails', () => {
|
||||
] as const
|
||||
const snap = chatSnapshotFixture({ nodes })
|
||||
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
|
||||
const pillProps: StatPillProps = {
|
||||
usePerformanceUsage: selector => selector('detailed'),
|
||||
t,
|
||||
useChat: bindSnapshotSelector(source),
|
||||
useProjection: () => undefined,
|
||||
}
|
||||
const view = render(
|
||||
<StatsPills
|
||||
usePerformanceUsage={selector => selector('detailed')}
|
||||
t={t}
|
||||
useChat={bindSnapshotSelector(source)}
|
||||
useProjection={() => undefined}
|
||||
/>,
|
||||
<>
|
||||
<ActivityPill {...pillProps} />
|
||||
<UsagePill {...pillProps} />
|
||||
</>,
|
||||
)
|
||||
expect(view.container.textContent).toBe('2 轮 3 步')
|
||||
// Window-fold counts carry no timed figure, so the pill is a static reading.
|
||||
|
||||
@@ -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(<TurnUsagePanel usage={{ uncachedInputTokens: 1, outputTokens: 1, totalTokens: 2 }} t={t} />)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
fireEvent.click(view.getByRole('dialog'))
|
||||
expect(view.queryByRole('dialog')).toBeTruthy()
|
||||
fireEvent.click(document.body)
|
||||
expect(view.queryByRole('dialog')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -496,7 +496,7 @@ export const InputBar = memo(function InputBar({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.dock}>
|
||||
<div className={css.dock} data-composer-dock>
|
||||
{variant === 'composer' && input !== undefined && sessionId !== undefined
|
||||
? renderSlot('conversation.composer.dock', {})
|
||||
: null}
|
||||
|
||||
@@ -500,7 +500,8 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
slotInject: '',
|
||||
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
|
||||
occupants: [
|
||||
'client-ui-chat StatsPills id \'stats\'',
|
||||
'client-ui-chat ActivityPill id \'activity\'',
|
||||
'client-ui-chat UsagePill id \'usage\'',
|
||||
],
|
||||
replaceRisk: 'none',
|
||||
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
|
||||
|
||||
Reference in new issue
Block a user