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