feat(ui-chat): add performance and usage display preference (#4478)

* feat(ui-chat): add performance and usage display preference

* fix(ui-chat): address performance preference review findings

* test(ui-chat): refresh Chinese turn footer snapshots

* docs(ui-chat): clarify completed-turn timing visibility

* test(ui-chat): capture preference failure screenshots

* fix(ui-chat): align performance preference with current icons

---------

Co-authored-by: lsdsjy <[email protected]>
This commit is contained in:
lsdsjyandlsdsjy authored and GitHub committed 2026-09-18 15:35:50 +08:00
1 parent 3bd8cfdf20
commit 3e216c3d64
104 files changed
+488 -519

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
2026-09-07-composer-session-stats-pills.md: d165aab471bc5958cb7a0b287d278a0ae48485ee
2026-09-07-composer-session-stats-pills.zh.md: dac136d4e60e50b026ed082eb7a006a80059c328
2026-09-07-composer-session-stats-pills.md: 50d3d3294ed012773657ba9849e45835c361d8be
2026-09-07-composer-session-stats-pills.zh.md: 97b91b601e62e8b06ab13a764babb4dd3b25bfce
@@ -10,6 +10,8 @@ The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `co
## Decision
The [performance and usage preference](2026-09-16-performance-usage-preference.md) governs statistic visibility and removes the completed-turn elapsed-time action.
`StatsPills` (packages/client/ui-chat/src/client/chat/StatsPills.tsx) replaces `StatsLine` on the same `conversation.composer.dock` slot; the losing variant is deleted, its shared helpers (`deriveStats`, `formatDuration`, `cacheHitPercent`, `billedInputTokens`) absorbed into the new module, and the dead `stats.llm`, `stats.toolCall`, `stats.ttftAverage`, `stats.tokensPerSecond`, and `stats.tokens` locale keys removed.
- **Two icon pills, two dialogs.** A gauge pill (new `IconGaugeOutlineRegular`, dial center optically dropped to y=8.75 because the bottom-open arc reads high) shows `{turns} 轮 {steps} 步` plus output TPS and click-opens the 会话统计 dialog (LLM time, tool time, average TTFT, TPS); a log with no timed figure would open an empty dialog, so that pill renders as a static reading instead of a button. A database pill (`IconDatabaseOutlineRegular`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, output, and cache write when non-zero — exact counts). Both dialogs wear the shared `stat-dialog` module (portal panel, anchored placement, outside-dismiss, optionally externally owned open state) extracted for exactly this two-consumer split; the pills row owns one exclusive open slot, so opening either dialog closes the other, and each button carries an explicit `aria-label` that separates with ` · ` the segments the aria-hidden sep glyph joins visually. [StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx) owns the zero cache-write omission.
@@ -10,6 +10,8 @@ Status: implemented
## 决定
[性能与用量偏好](2026-09-16-performance-usage-preference.zh.md)管理统计可见性,并移除已完成轮次的耗时操作。
`StatsPills`(packages/client/ui-chat/src/client/chat/StatsPills.tsx)在同一 `conversation.composer.dock` 插槽上取代 `StatsLine`;落选变体已删除,其共享工具函数(`deriveStats`、`formatDuration`、`cacheHitPercent`、`billedInputTokens`)并入新模块,废弃的 `stats.llm`、`stats.toolCall`、`stats.ttftAverage`、`stats.tokensPerSecond`、`stats.tokens` 文案键一并移除。
- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutlineRegular`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutlineRegular`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、输出,以及非零时的缓存写入——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。[StatsPills](../../../../packages/client/ui-chat/src/client/chat/StatsPills.tsx)负责缓存写入为零时省略该行的规则。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-performance-usage-preference.md
2026-09-16-performance-usage-preference.md: 36ab30561fccc6c824ee8483f5a533a9178f11c5
2026-09-16-performance-usage-preference.zh.md: ecfd19c41c861116d18829b355bf8c8df4ea929a
@@ -0,0 +1,31 @@
# Agent Note: Performance and usage detail preference
Status: implemented
English | [中文](2026-09-16-performance-usage-preference.zh.md)
## Problem
Always-visible counts and accounting controls add density to Chat even when readers only need output speed and cache effectiveness.
## Decision
Chat owns `ui-chat.performanceUsage` in its existing settings namespace. General Settings offers Compact and Detailed, with Detailed as the default to preserve access to accounting. Both the composer and turn-tail renderer consume the same accepted settings source through framework-bound hooks. Host settings own persistence, validation, and failed-write recovery.
Compact retains only available output speed and cache-hit percentage under the composer. These readings are plain text with no statistics dialog. Detailed retains session statistic dialogs and per-Turn usage. Neither mode renders the elapsed-time action in the completed-turn footer. This preference changes presentation, never telemetry collection, accounting, or Session events.
The [composer statistics decision](2026-09-07-composer-session-stats-pills.md) remains authoritative for accounting sources, dialog exclusivity, and composer spacing in Detailed mode.
The requested footer design prioritizes message actions and token accounting in both modes. Completed turns therefore expose no per-turn elapsed-time control; Detailed restores accounting detail, not that control. The running clock and session-wide timing statistics remain independent of this choice.
## Alternatives considered
**Default to Compact.** This would hide accounting controls for existing users without an explicit choice. The request defines both modes but no default, so Detailed preserves their access.
**Filter telemetry when Compact is selected.** This would discard information needed after switching to Detailed or reopening history. A display preference must not change recorded data.
## Consequences
Unavailable speed or cache-hit data is omitted rather than replaced with invented values. The settings scope provides one accepted value to all consumers without duplicating subscription state. Component coverage checks mode-specific visibility; the [recorded-session browser scenario](../../../../apps/web/tests/message-actions.e2e.ts) exercises selection, reload persistence, and Compact output.
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.
@@ -0,0 +1,31 @@
# Agent Note:性能与用量详细程度偏好
Status: implemented
[English](2026-09-16-performance-usage-preference.md) | 中文
## Problem
当读者只需要输出速度和缓存效果时,始终显示的计数和记账控件会增加聊天界面的密度。
## Decision
Chat 在既有设置命名空间中拥有 `ui-chat.performanceUsage`。通用设置提供简洁和详细选项,默认详细以保留记账入口。输入框和轮次页脚渲染器通过框架绑定的 Hook 消费同一个已接受设置源。Host 设置负责持久化、校验和写入失败恢复。
简洁模式仅在输入框下方保留可用的输出速度和缓存命中率。这些读数为纯文本,不提供统计卡片。详细模式保留会话统计卡片和每轮用量。两种模式均不渲染已完成轮次页脚中的耗时操作。该偏好只改变展示,不改变遥测收集、记账或 Session 事件。
[输入框统计决策](2026-09-07-composer-session-stats-pills.zh.md)仍负责详细模式中的记账来源、卡片互斥和输入框间距。
所要求的页脚设计在两种模式下均优先展示消息操作和 token 记账。因此,已完成轮次不提供单轮耗时控件;详细模式恢复记账详情,不恢复该控件。运行中的时钟和会话级计时统计独立于此选择。
## Alternatives considered
**默认简洁。** 这会在已有用户未明确选择时隐藏记账控件。需求定义了两种模式但未指定默认值,因此详细模式保留既有入口。
**选择简洁时过滤遥测。** 这会丢弃切换回详细模式或重新打开历史时需要的信息。展示偏好不能改变记录数据。
## Consequences
不可用的速度或缓存命中数据会被省略,不填入虚构值。设置作用域为所有消费者提供同一个已接受值,无需复制订阅状态。组件覆盖检查不同模式的可见性;[录制会话浏览器场景](../../../../apps/web/tests/message-actions.e2e.ts)验证选择、刷新持久化和简洁模式输出。
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
@@ -146,7 +146,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "Back to bottom"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -38,7 +38,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 9/1 {{clock}}
- button "Thought for a while"
- paragraph: The Cordis Plugin is running.
@@ -46,7 +45,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
- button "Copy"
- button "1 tool call" [expanded]
@@ -58,7 +56,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 9/1 {{clock}}
- button "Back to bottom"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -24,7 +24,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -22,7 +22,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -38,7 +38,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -24,7 +24,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -28,7 +28,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -33,7 +33,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -21,6 +21,8 @@
- button "Decrease font size"
- text: px Conversation display Controls process content in completed turns
- button "Compact"
- text: Performance & usage Choose how much performance and usage information to show
- button "Detailed"
- text: Developer tools Show tools and information for debugging and troubleshooting
- switch "Developer tools"
- text: Send behavior while busy What Enter and the Send button do while the agent is running; Cmd/Ctrl+Enter uses the other behavior
@@ -21,6 +21,8 @@
- button "减小字号"
- text: px 对话显示 控制已完成轮次的过程内容
- button "紧凑"
- text: 性能与用量 选择性能与用量信息展示的详细程度
- button "详细"
- text: 开发者工具 显示用于调试和排查问题的工具与信息
- switch "开发者工具"
- text: 繁忙时的发送行为 智能体运行时 Enter 键和发送按钮的行为;Cmd/Ctrl+Enter 使用另一行为
@@ -20,7 +20,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -42,7 +42,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m2 7/25 {{clock}}
- button "Copy"
- paragraph: r2
@@ -50,7 +49,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m3 7/25 {{clock}}
- button "Copy"
- paragraph: r3
@@ -58,7 +56,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m4 7/25 {{clock}}
- button "Copy"
- paragraph: r4
@@ -66,7 +63,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m5 7/25 {{clock}}
- button "Copy"
- paragraph: r5
@@ -74,7 +70,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m6 7/25 {{clock}}
- button "Copy"
- paragraph: r6
@@ -82,7 +77,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m7 7/25 {{clock}}
- button "Copy"
- paragraph: r7
@@ -90,7 +84,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m8 7/25 {{clock}}
- button "Copy"
- paragraph: r8
@@ -98,7 +91,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m9 7/25 {{clock}}
- button "Copy"
- paragraph: r9
@@ -106,7 +98,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m10 7/25 {{clock}}
- button "Copy"
- paragraph: r10
@@ -114,7 +105,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m11 7/25 {{clock}}
- button "Copy"
- paragraph: r11
@@ -122,7 +112,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m12 7/25 {{clock}}
- button "Copy"
- paragraph: r12
@@ -130,7 +119,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m13 7/25 {{clock}}
- button "Copy"
- paragraph: r13
@@ -138,7 +126,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m14 7/25 {{clock}}
- button "Copy"
- paragraph: r14
@@ -146,7 +133,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m15 7/25 {{clock}}
- button "Copy"
- paragraph: r15
@@ -154,7 +140,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m16 7/25 {{clock}}
- button "Copy"
- paragraph: r16
@@ -162,7 +147,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m17 7/25 {{clock}}
- button "Copy"
- paragraph: r17
@@ -170,7 +154,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m18 7/25 {{clock}}
- button "Copy"
- paragraph: r18
@@ -178,7 +161,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m19 7/25 {{clock}}
- button "Copy"
- paragraph: r19
@@ -186,7 +168,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m20 7/25 {{clock}}
- button "Copy"
- paragraph: r20
@@ -194,7 +175,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m21 7/25 {{clock}}
- button "Copy"
- paragraph: r21
@@ -202,7 +182,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m22 7/25 {{clock}}
- button "Copy"
- paragraph: r22
@@ -210,7 +189,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m23 7/25 {{clock}}
- button "Copy"
- paragraph: r23
@@ -218,7 +196,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m24 7/25 {{clock}}
- button "Copy"
- paragraph: r24
@@ -226,7 +203,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m25 7/25 {{clock}}
- button "Copy"
- paragraph: r25
@@ -234,7 +210,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m26 7/25 {{clock}}
- button "Copy"
- paragraph: r26
@@ -242,7 +217,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m27 7/25 {{clock}}
- button "Copy"
- paragraph: r27
@@ -250,7 +224,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}} m28 7/25 {{clock}}
- button "Copy"
- paragraph: r28
@@ -258,7 +231,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: 7/25 {{clock}}
- button "Back to bottom"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -23,7 +23,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -20,7 +20,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
+31 -2
View File
@@ -11,7 +11,7 @@ import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
launchWebScaffold, parseSeedFixture, renderSeedFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
@@ -313,6 +313,35 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
})
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]')
await page.getByRole('button', { name: 'Settings', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Settings', exact: true })
const row = dialog.getByText('Performance & usage', { exact: true }).locator('../..')
await row.getByRole('button', { name: 'Detailed', exact: true }).click()
await page.getByRole('menuitem', { name: 'Compact', exact: true }).click()
await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).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 page.getByRole('dialog').count()).toBe(0)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'compact.expected.md'), await captureStableAria(page, '[data-composer-stats]', scaffold.workspaceCwd), MODE)
const warningStart = tripwire.warnings.length
await page.reload()
await page.getByRole('button', { name: 'Settings', exact: true }).click()
await row.getByRole('button', { name: 'Compact', exact: true }).waitFor()
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await row.getByRole('button', { name: 'Compact', exact: true }).click()
await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click()
await expect.poll(() => scaffold.ctx.settings.get('ui-chat')).toMatchObject({ performanceUsage: 'detailed' })
await dialog.getByRole('button', { name: 'Close', exact: true }).click()
await expect.poll(() => stats.locator('button').count()).toBe(2)
expect(await page.locator('[data-turn-tail]').getByRole('button', { name: /Ran for/ }).count()).toBe(0)
})
it.skipIf(MODE === 'record')('forks through the settled-message and session-row actions', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-fork'))
// The second answer is followed by another completed user turn in the source.
@@ -380,6 +409,6 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
it.skipIf(MODE === 'record')('issued zero model calls and kept a closed inventory', async () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['fork.expected.md', 'ui.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['compact.expected.md', 'fork.expected.md', 'ui.expected.md'])
})
})
-1
View File
@@ -70,7 +70,6 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => {
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
.replace(/\b\d{1,2}\/\d{1,2}(?= \{\{clock\}\})/g, '{{date}}')
.replace(/\{\{date\}\} (?=\{\{clock\}\} Ran for)/g, '')
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
expect(tripwire.pageErrors).toEqual([])
+3 -14
View File
@@ -177,11 +177,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const trigger = page.getByRole('button', { name: /Usage 15\.8K tok/ })
await expect.poll(() => trigger.count(), { timeout: 10_000 }).toBe(1)
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
// The usage pill carries the icon and the turn total; the time pill beside
// it carries the run time, and both keep their details dialog-only.
expect(await trigger.textContent()).toBe('Usage 15.8K tok')
const timeTrigger = page.getByRole('button', { name: /^Ran for \S+$/ })
expect(await timeTrigger.count()).toBe(1)
expect(await timeTrigger.count()).toBe(0)
expect(await page.locator('[data-turn-tail]').getByText(/tok\/s|TTFT/).count()).toBe(0)
expect(await page.getByRole('dialog').count()).toBe(0)
@@ -198,12 +196,6 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await page.keyboard.press('Escape')
expect(await page.getByRole('dialog').count()).toBe(0)
await timeTrigger.click()
const timeDialog = page.getByRole('dialog', { name: 'Turn time and speed' })
expect(await timeDialog.count()).toBe(1)
expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
await page.keyboard.press('Escape')
await trigger.click()
const expanded = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
@@ -211,12 +203,9 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
const warningStart = tripwire.warnings.length
await page.reload({ waitUntil: 'load' })
await expect.poll(() => timeTrigger.count(), { timeout: 15_000 }).toBe(1)
await expect.poll(() => trigger.count(), { timeout: 15_000 }).toBe(1)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
await timeTrigger.click()
expect(await timeDialog.count()).toBe(1)
expect(await timeDialog.getByText(/tok\/s/).count()).toBe(0)
expect(await timeDialog.getByText('Time to first token (TTFT)', { exact: true }).count()).toBe(0)
expect(await timeTrigger.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 120_000)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: 95066b18f125df32a63d4ba6336bd0e83e0518a2
README.zh.md: f4697aac4f8ef9c19fe05449affb142ac4e84060
README.md: 7f3d905b026edeb538de87dc5e54e0f05cd3c3bc
README.zh.md: 118fc29fdb4960dfc1606d7e9cd0a1ccaea9843d
+3 -1
View File
@@ -51,7 +51,9 @@ Generic command rows retain the ordinary command glyph in every lifecycle state;
A completed Turn shows an expandable usage row only when the loaded window includes `turn/start` and every started model attempt reports safe, exact usage. The row omits unavailable optional buckets. Incomplete or contradictory accounting hides the complete disclosure instead of presenting a partial total.
After Assistant replies settle, the completed-turn timing dialog omits TTFT and decoding speed, both after live replies and after reopening history. Elapsed turn time remains available. The Session Stats pill reads timing independently from its durable projection.
Settings → General → Performance & usage 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.
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.
<a id="completed-turn-footer"></a>
## Completed-turn footer
+3 -1
View File
@@ -51,7 +51,9 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
只有当已加载窗口包含 `turn/start`,且每次已启动的模型尝试都报告安全、精确的用量时,已完成轮次才显示可展开的用量行。该行会省略不可用的可选用量桶。记账不完整或相互矛盾时,整个详情都不显示,避免把部分总量冒充完整结果。
Assistant 回复结算后,已完成轮次的计时对话框不显示 TTFT 和解码速度,实时回复完成后和重新打开历史后都如此。轮次总耗时仍可用。Session Stats 药丸从其持久化投影独立读取计时。
设置 → 通用设置 → 性能与用量将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。
在非回环地址浏览器中,设置作用域无法持久化写入,因此该偏好仅在当前进程内生效。明确选择会立即更新所有使用方;回环地址浏览器收到 Host 已接受的设置后会同步当前值。
<a id="completed-turn-footer"></a>
## 已完成轮次的页脚
+13 -1
View File
@@ -1,4 +1,4 @@
/** Chat transcript preferences stored in the Host user-settings document. */
/** Chat display preferences stored in the Host user-settings document. */
import z from '@deepseek-ai/schemastery'
@@ -17,13 +17,25 @@ export type TranscriptViewMode = typeof TRANSCRIPT_VIEW_MODES[number]
/** Default preserves the compact process disclosure introduced by Chat. */
export const DEFAULT_TRANSCRIPT_VIEW_MODE: TranscriptViewMode = 'compact'
/** Performance and usage detail levels accepted by user settings. */
export const PERFORMANCE_USAGE_MODES = ['compact', 'detailed'] as const
/** Performance and usage presentation. */
export type PerformanceUsageMode = typeof PERFORMANCE_USAGE_MODES[number]
/** Preserve detailed accounting for users without an explicit preference. */
export const DEFAULT_PERFORMANCE_USAGE: PerformanceUsageMode = 'detailed'
/** Durable Chat section shared by the Host schema and browser scope. */
export interface ChatSettings {
/** Presentation mode for completed Turn process content. */
transcriptView: TranscriptViewMode
/** Detail level for composer statistics and completed-Turn usage. */
performanceUsage: PerformanceUsageMode
}
/** Durable Chat schema; also the wire envelope the browser scope validates against. */
export const ChatSettingsSchema: z<ChatSettings> = z.object({
performanceUsage: z.union([...PERFORMANCE_USAGE_MODES]).default(DEFAULT_PERFORMANCE_USAGE),
[TRANSCRIPT_VIEW_FIELD]: z.union([...TRANSCRIPT_VIEW_MODES]).default(DEFAULT_TRANSCRIPT_VIEW_MODE),
})
+19 -4
View File
@@ -35,6 +35,8 @@ import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/Tr
import { createChatStore } from './stores.ts'
import { TranscriptViewPolicy } from './transcript-view.ts'
import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../chat-settings.ts'
import { PerformanceUsageRow, type PerformanceUsageRowInjected } from './settings/PerformanceUsageRow.tsx'
import { PerformanceUsagePolicy } from './performance-usage.ts'
import { useTurnDataValue } from './chat/use-turn-data.ts'
const CHAT_NODE_INJECT: ChatNodeTurnDataInjected = {
@@ -70,7 +72,6 @@ export function apply(ctx: Context): void {
return source
}
registerConversationNodes(ctx)
registerChatNodeRenderers(ctx)
ctx.uiSession.provide({
hooks: ['chat'],
resolve: binding => ({ hooks: { chat: chatSource(binding) } }),
@@ -80,9 +81,22 @@ export function apply(ctx: Context): void {
const t = ctx.locale.bind(NS)
const chatStore = createChatStore()
const chatScrollPositions = new Map<SessionId, ChatScrollPosition>()
const transcriptView = new TranscriptViewPolicy(
ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE }),
)
const chatSettings = ctx.settingsScope.bind<ChatSettings>({ namespace: CHAT_SETTINGS_NAMESPACE })
const transcriptView = new TranscriptViewPolicy(chatSettings)
const performancePolicy = new PerformanceUsagePolicy(chatSettings)
const performanceUsage = performancePolicy.mode
registerChatNodeRenderers(ctx, performanceUsage)
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
id: 'performance-usage',
order: 13,
locale: NS,
inject: (): PerformanceUsageRowInjected => ({
hooks: { performanceUsage },
setPerformanceUsage: (mode) => { performancePolicy.setMode(mode) },
}),
}, PerformanceUsageRow))
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
name: 'settings.general.item',
@@ -179,6 +193,7 @@ export function apply(ctx: Context): void {
ctx.slots.inject('conversation.composer.dock', () =>
ctx.slots.register({
name: 'conversation.composer.dock', id: 'stats', order: 0, locale: NS,
inject: () => ({ hooks: { performanceUsage } }),
}, StatsPills))
ctx.slots.inject('conversation.approval.detail', () =>
@@ -1,6 +1,5 @@
// Session stats under the composer, split into two icon pills: a gauge pill
// (turn/step counts + output speed) opening the time-and-speed dialog, and a
// database pill (total tokens + cache hit) opening the token-usage dialog.
// 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).
@@ -9,11 +8,11 @@ import { memo, useMemo, useState } 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'
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { InjectFace, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
import type {} from '@deepseek-ai/dsh-session-stats/client'
import type { TokenUsageProjection } from '@deepseek-ai/dsh-token-meter/client'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import type { ChatViewSlotProps, PerformanceUsageInjected } from '../contract/slots.ts'
import type { ChatSnapshot } from '../contract/snapshot.ts'
import { formatTokensPerSecond } from './message-chrome.ts'
import { assistantStepReading } from '../contract/turn-metrics.ts'
@@ -121,7 +120,7 @@ export function billedInputTokens(usage: TokenUsageProjection): number {
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsPillsProps {
export interface StatsPillsProps extends InjectFace<PerformanceUsageInjected> {
useChat: SnapshotSelectorHook<ChatSnapshot>
useProjection: UseProjection
/** The owning dock's locale seat. */
@@ -314,7 +313,8 @@ function UsagePill({ usage, t, dialog }: {
)
}
export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
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.
@@ -329,9 +329,24 @@ export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }
// 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>}
{cacheHit !== null && (
<span className={css.pill}><IconDatabaseOutlineRegular />{t('stats.cacheHit', { percent: cacheHit })}</span>
)}
</div>
)
}
if (stats.steps === 0 && !hasTokens) return null
return (
<div className={css.root}>
<div className={css.root} data-composer-stats>
{stats.steps > 0 && (
<TimePill
stats={stats}
@@ -1,18 +1,20 @@
import { memo } from 'react'
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNodeViewProps, TurnTailOwnerProps } from '../contract/slots.ts'
import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps } from '../contract/slots.ts'
import { MessageIconActions } from './MessageIconActions.tsx'
import { TurnTimePanel, TurnUsagePanel } from './TurnUsagePanel.tsx'
import { TurnUsagePanel } from './TurnUsagePanel.tsx'
import { assistantText } from './turn-assistant.ts'
import css from './TurnTailNodeView.module.css'
type TurnTailNodeViewProps = ChatNodeViewProps<'turn-tail'>
& PropsRenderSlots<'conversation.chat.turnTail' | 'conversation.chat.assistant-actions'>
& InjectFace<PerformanceUsageInjected>
/** Turn-local actions and feature tail over the Location index, independent of Assistant placement. */
export const TurnTailNodeView = memo(function TurnTailNodeView({
node, openFile, forkAt, renderSlot, t, useChat,
node, openFile, forkAt, renderSlot, t, useChat, usePerformanceUsage,
}: TurnTailNodeViewProps) {
const detailed = usePerformanceUsage(mode => mode) === 'detailed'
const data = node.data
const hasLaterChatNode = useChat(snapshot =>
snapshot.locations.getTurn(data.turn).at(-1) !== node.key)
@@ -25,9 +27,6 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
const owner: TurnTailOwnerProps = { turn, seq: closing?.finalNode.seq ?? data.seq, openFile }
const tail = renderSlot('conversation.chat.turnTail', owner)
if (closing === null) return tail === null ? null : <div className={css.root} data-turn-tail={data.turn}>{tail}</div>
const runMs = turn.start === undefined || turn.end === undefined
? undefined
: Math.max(0, turn.end.time - turn.start.time)
// Interruption-frozen partials carry no messageId, so they address no
// durable message and contribute no per-message actions.
const messageId = closing.finalNode.messageId
@@ -49,19 +48,9 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({
branchUnavailable={data.branchUnavailable || hasLaterChatNode}
className={css.actions}
extraActions={assistantActions}
usageAction={(
<>
{data.tokenUsage !== undefined && <TurnUsagePanel usage={data.tokenUsage} t={t} />}
{runMs !== undefined && (
<TurnTimePanel
runMs={runMs}
tokensPerSecond={data.tokensPerSecond}
ttftMs={data.ttftMs}
t={t}
/>
)}
</>
)}
usageAction={detailed && data.tokenUsage !== undefined
? <TurnUsagePanel usage={data.tokenUsage} t={t} />
: null}
t={t}
/>
</div>
@@ -1,13 +1,9 @@
// Icon-row Turn-stat actions: a database pill labelled with the turn total
// click-opens the per-Turn usage dialog, and a clock pill labelled with the
// turn wall time click-opens the Turn-time dialog. Both sit right of the
// branch action in the tail's IconActions row, ahead of the plain clock text.
/** Completed-Turn token usage action and its accounting details dialog. */
import { createPortal } from 'react-dom'
import { IconClockOutlineRegular, IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconDatabaseOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TurnTokenUsage } from '../contract/chat-nodes.ts'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatLatencySeconds, formatRunDuration, formatTokensPerSecond } from './message-chrome.ts'
import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts'
import css from './TurnUsagePanel.module.css'
@@ -19,17 +15,6 @@ export interface TurnUsagePanelProps {
t: ChatViewSlotProps['t']
}
export interface TurnTimePanelProps {
/** Turn wall time in ms, the pill's label. */
runMs: number
/** Turn decode throughput, a dialog row when known. */
tokensPerSecond?: number | undefined
/** Turn first-step TTFT in ms, a dialog row when known. */
ttftMs?: number | undefined
/** The owning view's locale seat, passed down as a plain prop. */
t: ChatViewSlotProps['t']
}
function formatCompactCount(value: number, t: ChatViewSlotProps['t']): string {
return t('message.turnUsage.count', { count: formatTokens(value, t) })
}
@@ -123,60 +108,3 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
</span>
)
}
/**
* Turn-time IconActions pill with a click-open Turn-time details dialog.
* @param props - Turn timing facts and locale seat.
* @returns The clock-and-duration trigger and, while open, its portaled dialog anchored above the trigger.
*/
export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePanelProps) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
return (
<span ref={rootRef} className={css.root}>
<button
type="button"
className={css.trigger}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<IconClockOutlineRegular />
<span className={css.label}>{t('message.ranFor', { duration: formatRunDuration(runMs, t) })}</span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={dialogCss.panel}
role="dialog"
aria-label={t('message.turnTime.title')}
style={pos ?? MEASURE_STYLE}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconClockOutlineRegular />
{t('message.turnTime.title')}
</span>
</div>
<div className={dialogCss.titleRule} aria-hidden />
<dl className={dialogCss.details} data-turn-time-details>
<dt>{t('message.turnTime.duration')}</dt>
<dd>{formatRunDuration(runMs, t)}</dd>
{tokensPerSecond !== undefined && (
<>
<dt>{t('message.turnTime.speed')}</dt>
<dd>{t('message.tokensPerSecond', { tps: formatTokensPerSecond(tokensPerSecond) })}</dd>
</>
)}
{ttftMs !== undefined && (
<>
<dt>{t('message.turnTime.ttft')}</dt>
<dd>{t('duration.seconds', { seconds: formatLatencySeconds(ttftMs) })}</dd>
</>
)}
</dl>
</div>,
document.body,
)}
</span>
)
}
@@ -35,7 +35,7 @@ export function msUntilNextLocalMidnight(ms: number): number {
}
/**
* Localized elapsed-time label shared by running and settled turn chrome.
* Localized elapsed-time label for the running conversation clock.
* @param ms - Elapsed duration in milliseconds (negatives clamp to zero).
* @param t - Translate seat supplying the duration templates.
* @returns Display string in whole seconds; minutes and seconds once the
@@ -55,17 +55,6 @@ export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
: t('duration.seconds', { seconds })
}
/**
* Sub-turn latency figure: one decimal under ten seconds, whole seconds
* beyond. Unit-less so the locale template owns the second suffix.
* @param ms - Latency in milliseconds (negatives clamp to zero).
* @returns Display number in seconds without unit.
*/
export function formatLatencySeconds(ms: number): string {
const s = Math.max(0, ms) / 1000
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s))
}
/**
* Decode-throughput figure: whole tokens from ten up, one decimal below.
* @param tps - Tokens per second.
@@ -1,4 +1,6 @@
import type { Context } from '@deepseek-ai/cordis'
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
import type { PerformanceUsageMode } from '../../chat-settings.ts'
import { NS } from '../locale.ts'
import { AssistantNodeView } from './AssistantNodeView.tsx'
import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx'
@@ -13,8 +15,9 @@ import { TurnTailNodeView } from './TurnTailNodeView.tsx'
/**
* Register this package's business renderers behind the keyed Chat Node seat.
* @param ctx - owning UI Conversation context.
* @param performanceUsage - live statistics detail preference.
*/
export function registerChatNodeRenderers(ctx: Context): void {
export function registerChatNodeRenderers(ctx: Context, performanceUsage: ObservableSnapshot<PerformanceUsageMode>): void {
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
{ name: 'conversation.chat.node', key: 'user', locale: NS }, UserMessageNodeView))
ctx.slots.inject('conversation.chat.node', () => ctx.slots.register(
@@ -47,6 +50,7 @@ export function registerChatNodeRenderers(ctx: Context): void {
name: 'conversation.chat.node',
key: 'turn-tail',
locale: NS,
inject: () => ({ hooks: { performanceUsage } }),
children: {
'conversation.chat.turnTail': { kind: 'list', scope: 'session' },
'conversation.chat.assistant-actions': { kind: 'list', scope: 'session' },
@@ -91,8 +91,6 @@ export interface TurnTailChatData {
readonly closing: FinalAssistantChatData | null
/** Whether non-rendered later evidence makes the closing seq non-tail. */
readonly branchUnavailable: boolean
readonly ttftMs?: number
readonly tokensPerSecond?: number
/** Exact per-Turn accounting; absent when the loaded evidence is incomplete. */
readonly tokenUsage?: TurnTokenUsage
}
@@ -9,7 +9,7 @@ import type {
InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
SlotHookFactory, SnapshotSelectorHook,
} from '@deepseek-ai/dsh-client-ui-slots'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { createChatStore } from '../stores.ts'
@@ -19,7 +19,7 @@ import type {
ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation,
} from './snapshot.ts'
import type { TurnProcessSpec } from './turn-process.ts'
import type { TranscriptViewMode } from '../../chat-settings.ts'
import type { PerformanceUsageMode, TranscriptViewMode } from '../../chat-settings.ts'
/** Selector hook over the current Conversation binding's Chat target. */
export type UseChat = SnapshotSelectorHook<ChatSnapshot>
@@ -128,6 +128,14 @@ export interface ChatScrollPosition {
readonly scrollTop: number
}
/** Shared settings source for the performance row, composer, and turn tail. */
export interface PerformanceUsageInjected {
hooks: {
/** Accepted performance and usage detail preference. */
performanceUsage: ObservableSnapshot<PerformanceUsageMode>
}
}
/** Business callbacks injected into the Chat view. */
export interface ChatViewInjected {
hooks: {
@@ -1,17 +1,9 @@
// Latency/throughput folds shared by the settled turn footer and StatsPills.
// Assistant timing readings used by session statistics.
import type {
AssistantMessageNode, ConversationNode,
AssistantMessageNode,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
/** Latency and decode-throughput readings for one turn's footer. */
export interface TurnMetrics {
/** First-step TTFT in ms; absent when that step carries no recorded timing. */
ttftMs?: number
/** Decode throughput over steps carrying both timing and provider usage. */
tokensPerSecond?: number
}
/** One assistant step's derivable latency facts; null marks an unrecorded part. */
export interface StepReading {
/** step/start → first token delta, in ms. */
@@ -49,51 +41,3 @@ export function assistantStepReading(node: AssistantNode): StepReading {
: null
return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) }
}
interface TurnFold {
firstStep: number
firstStepTtftMs: number | null
decodeMs: number
outputTokens: number
sampled: boolean
}
/**
* Fold assistant nodes into per-turn footer metrics.
*
* TTFT is the turn's lowest-step request-dispatch-to-first-token reading, so
* it is only meaningful when the turn's start is inside
* the loaded window (the caller gates on `turnTimings`, which shares that
* window). Throughput divides summed output tokens by summed decode wall time,
* counting only steps that carry both.
* @param nodes - Snapshot nodes of the loaded window.
* @returns Turn number → available metrics; turns with none are absent.
*/
export function deriveTurnMetrics(nodes: readonly ConversationNode[]): Map<number, TurnMetrics> {
const folds = new Map<number, TurnFold>()
for (const node of nodes) {
if (node.kind !== 'assistant') continue
const reading = assistantStepReading(node)
let fold = folds.get(node.turn)
if (fold === undefined) {
fold = { firstStep: node.step, firstStepTtftMs: reading.ttftMs, decodeMs: 0, outputTokens: 0, sampled: false }
folds.set(node.turn, fold)
} else if (node.step < fold.firstStep) {
fold.firstStep = node.step
fold.firstStepTtftMs = reading.ttftMs
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
fold.decodeMs += reading.decodeMs
fold.outputTokens += reading.outputTokens
fold.sampled = true
}
}
const metrics = new Map<number, TurnMetrics>()
for (const [turn, fold] of folds) {
const entry: TurnMetrics = {}
if (fold.firstStepTtftMs !== null) entry.ttftMs = fold.firstStepTtftMs
if (fold.sampled && fold.decodeMs > 0) entry.tokensPerSecond = fold.outputTokens / (fold.decodeMs / 1000)
if (entry.ttftMs !== undefined || entry.tokensPerSecond !== undefined) metrics.set(turn, entry)
}
return metrics
}
@@ -9,7 +9,6 @@ import { deriveTurnTokenUsage } from '@deepseek-ai/dsh-token-meter/client'
import type {
AssistantChatData, FinalAssistantChatData, TurnTailChatData,
} from '../contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../contract/turn-metrics.ts'
import { CHAT_SYNTHETIC_SEQ_OFFSETS, chatNode } from './common.ts'
import { toAssistantBlocks } from './event-projection.ts'
@@ -146,7 +145,6 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
latestTranscriptSeq = candidate
}
}
const metrics = deriveTurnMetrics(finalized.map(candidate => candidate.finalNode)).get(end.event.data.turn)
const tokenUsage = context.start?.event.type === 'turn/start'
? deriveTurnTokenUsage(context.matches.map(match => match.event).filter(isSessionEvent))
: undefined
@@ -156,8 +154,6 @@ function tailData(context: ConversationNodeContext<TurnTailState>): TurnTailChat
time: end.event.time,
closing,
branchUnavailable: closing === null || latestTranscriptSeq !== closing.finalNode.seq,
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
...tokenUsage === undefined ? {} : { tokenUsage },
}
}
+8 -10
View File
@@ -27,6 +27,10 @@ export const zh = {
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
'chat.turnNavigation.turn': '第 {turn} 轮',
'settings.performance.title': '性能与用量',
'settings.performance.description': '选择性能与用量信息展示的详细程度',
'settings.performance.compact': '简洁',
'settings.performance.detailed': '详细',
'settings.transcript.title': '对话显示',
'settings.transcript.description': '控制已完成轮次的过程内容',
'settings.transcript.normal': '标准',
@@ -81,7 +85,6 @@ export const zh = {
'message.turnError': '本轮运行失败',
'message.maxTokens': '已达到输出 token 上限',
'message.maxTokens.hint': '回答被截断,已有输出保留在对话中。发送“继续”可让模型接着输出。',
'message.ranFor': '用时 {duration}',
'message.tokensPerSecond': '{tps} tok/s',
'message.turnUsage.title': '本轮用量',
'message.turnUsage.consumed': '用量 {total}',
@@ -93,10 +96,6 @@ export const zh = {
'message.turnUsage.output': '输出',
'message.turnUsage.reasoning': '(其中推理 {tokens})',
'message.turnUsage.count': '{count} tok',
'message.turnTime.title': '本轮用时和速度',
'message.turnTime.duration': '本轮总用时',
'message.turnTime.speed': '输出速度(TPS)',
'message.turnTime.ttft': '首 token 用时(TTFT)',
'duration.seconds': '{seconds}秒',
'duration.minutes': '{minutes}分{seconds}秒',
'duration.hours': '{hours}小时{minutes}分{seconds}秒',
@@ -138,6 +137,10 @@ export const en = {
'chat.turnNavigation.jump': 'Jump to turn {turn}',
'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
'chat.turnNavigation.turn': 'Turn {turn}',
'settings.performance.title': 'Performance & usage',
'settings.performance.description': 'Choose how much performance and usage information to show',
'settings.performance.compact': 'Compact',
'settings.performance.detailed': 'Detailed',
'settings.transcript.title': 'Conversation display',
'settings.transcript.description': 'Controls process content in completed turns',
'settings.transcript.normal': 'Normal',
@@ -192,7 +195,6 @@ export const en = {
'message.turnError': 'This turn failed',
'message.maxTokens': 'Output token limit reached',
'message.maxTokens.hint': 'The reply was cut off; earlier output is preserved in the conversation. Send "continue" to let the model resume.',
'message.ranFor': 'Ran for {duration}',
'message.tokensPerSecond': '{tps} tok/s',
'message.turnUsage.title': 'Turn usage',
'message.turnUsage.consumed': 'Usage {total}',
@@ -204,10 +206,6 @@ export const en = {
'message.turnUsage.output': 'Output',
'message.turnUsage.reasoning': ' ({tokens} reasoning)',
'message.turnUsage.count': '{count} tok',
'message.turnTime.title': 'Turn time and speed',
'message.turnTime.duration': 'Total run time',
'message.turnTime.speed': 'Tokens per second (TPS)',
'message.turnTime.ttft': 'Time to first token (TTFT)',
'duration.seconds': '{seconds}s',
'duration.minutes': '{minutes}m {seconds}s',
'duration.hours': '{hours}h {minutes}m {seconds}s',
@@ -0,0 +1,30 @@
/** Performance detail preference with process-local choices on memory-only settings scopes. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import type { SettingsScope } from '@deepseek-ai/dsh-client-ui-settings/client'
import { DEFAULT_PERFORMANCE_USAGE, type ChatSettings, type PerformanceUsageMode } from '../chat-settings.ts'
/** Shared live preference for the settings row and chat statistics. */
export class PerformanceUsagePolicy {
/** Current choice, reconciled with accepted Host settings when available. */
readonly mode = createSnapshotStore<PerformanceUsageMode>(DEFAULT_PERFORMANCE_USAGE)
/** @param host - Chat settings scope, durable on loopback and memory-only elsewhere. */
constructor(private readonly host: SettingsScope<ChatSettings>) {
const adopt = (): void => {
const accepted = host.getSnapshot().value?.performanceUsage
if (accepted !== undefined) this.mode.set(accepted)
}
host.subscribe(adopt)
adopt()
}
/**
* Publish a choice immediately and persist it when the scope supports writes.
* @param mode - Statistics detail selected by the user.
*/
setMode(mode: PerformanceUsageMode): void {
if (mode === this.mode.getSnapshot()) return
this.mode.set(mode)
void this.host.set('performanceUsage', mode)
}
}
@@ -0,0 +1,46 @@
/** General Settings row for performance and usage detail. */
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { PerformanceUsageMode } from '../../chat-settings.ts'
import type { PerformanceUsageInjected } from '../contract/slots.ts'
import type { ChatKey } from '../locale.ts'
import { PreferenceRow } from './PreferenceRow.tsx'
/** Registration-side performance and usage preference face. */
export interface PerformanceUsageRowInjected extends PerformanceUsageInjected {
/** Change the performance and usage detail. */
setPerformanceUsage: (mode: PerformanceUsageMode) => void
}
/** Full Settings-row props. */
export type PerformanceUsageRowProps =
PropsRuntime<'settings.general.item'>
& PropsLocale<'chat'>
& InjectFace<PerformanceUsageRowInjected>
const OPTIONS: readonly { id: PerformanceUsageMode; label: ChatKey }[] = [
{ id: 'compact', label: 'settings.performance.compact' },
{ id: 'detailed', label: 'settings.performance.detailed' },
]
/**
* Render the performance and usage detail selector.
* @param props - composed Settings slot props.
* @returns the preference row.
*/
export function PerformanceUsageRow({ usePerformanceUsage, setPerformanceUsage, t }: PerformanceUsageRowProps) {
const mode = usePerformanceUsage(value => value)
const selectedLabel = mode === 'detailed'
? 'settings.performance.detailed'
: 'settings.performance.compact'
return (
<PreferenceRow
title={t('settings.performance.title')}
description={t('settings.performance.description')}
value={mode}
selectedLabel={t(selectedLabel)}
options={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
onSelect={(value) => { setPerformanceUsage(value as PerformanceUsageMode) }}
/>
)
}
@@ -1,4 +1,4 @@
/* Completed-Turn transcript preference row: label plus selector pill. */
/* Chat preference row: label plus selector pill. */
.row {
display: flex;
@@ -0,0 +1,56 @@
/** Localized two-column selector shared by Chat preference rows. */
import { useState } from 'react'
import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './PreferenceRow.module.css'
/**
* Render a preference label and its menu.
* @param props - localized copy, selected value, choices, and mutation callback.
* @returns the settings row.
*/
export function PreferenceRow({ title, description, value, selectedLabel, options, onSelect }: {
title: string
description: string
value: string
selectedLabel: string
options: readonly { id: string; label: string }[]
onSelect: (value: string) => void
}) {
const [open, setOpen] = useState(false)
const closeMenu = () => { setOpen(false) }
const selectMode = (id: string) => {
closeMenu()
onSelect(id)
}
const selector = (
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(value => !value) }}
>
{selectedLabel}
<IconChevronDownOutlineRegular className={css.chevron} />
</button>
)
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{title}</div>
<div className={css.desc}>{description}</div>
</div>
<Menu
open={open}
onClose={closeMenu}
items={options}
selectedId={value}
onSelect={selectMode}
align="end"
portal
anchor={selector}
/>
</div>
)
}
@@ -1,12 +1,10 @@
/** General Settings row for completed-Turn transcript presentation. */
import { useState } from 'react'
import type { SnapshotStore } from '@deepseek-ai/dsh-client-store'
import { IconChevronDownOutlineRegular, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { TranscriptViewMode } from '../../chat-settings.ts'
import type { ChatKey } from '../locale.ts'
import css from './TranscriptViewRow.module.css'
import { PreferenceRow } from './PreferenceRow.tsx'
/** Registration-side transcript preference face. */
export interface TranscriptViewRowInjected {
@@ -36,44 +34,17 @@ const OPTIONS: readonly { id: TranscriptViewMode; label: ChatKey }[] = [
*/
export function TranscriptViewRow({ useTranscriptView, setTranscriptView, t }: TranscriptViewRowProps) {
const mode = useTranscriptView(value => value)
const [open, setOpen] = useState(false)
const selectedLabel = mode === 'normal'
? 'settings.transcript.normal'
: 'settings.transcript.compact'
const closeMenu = () => { setOpen(false) }
const selectMode = (id: string) => {
closeMenu()
setTranscriptView(id as TranscriptViewMode)
}
const selector = (
<button
type="button"
className={css.selector}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => { setOpen(value => !value) }}
>
{t(selectedLabel)}
<IconChevronDownOutlineRegular className={css.chevron} />
</button>
)
return (
<div className={css.row}>
<div className={css.rowText}>
<div className={css.title}>{t('settings.transcript.title')}</div>
<div className={css.desc}>{t('settings.transcript.description')}</div>
</div>
<Menu
open={open}
onClose={closeMenu}
items={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
selectedId={mode}
onSelect={selectMode}
align="end"
portal
anchor={selector}
/>
</div>
<PreferenceRow
title={t('settings.transcript.title')}
description={t('settings.transcript.description')}
value={mode}
selectedLabel={t(selectedLabel)}
options={OPTIONS.map(option => ({ id: option.id, label: t(option.label) }))}
onSelect={(value) => { setTranscriptView(value as TranscriptViewMode) }}
/>
)
}
@@ -23,6 +23,7 @@ import {
import type {
ChatNodeTurnDataInjected, ChatSnapshot, TranscriptViewRowInjected, UseChatNodeTurnData,
} 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'
declare module '@deepseek-ai/dsh-client-ui-conversation/client' {
@@ -93,7 +94,7 @@ describe('Chat apply wiring', () => {
expect(b.runtime.slots.entries('conversation.composer.dock').map(row => row.options.id))
.toEqual(['stats'])
expect(b.runtime.slots.entries('settings.general.item').map(row => row.options.id))
.toEqual(['transcript-view', 'composer-enter'])
.toEqual(['transcript-view', 'performance-usage', 'composer-enter'])
await b.runtime.dispose()
})
@@ -109,12 +110,31 @@ describe('Chat apply wiring', () => {
expect(b.chatSettings.set).toHaveBeenCalledWith('transcriptView', 'normal')
b.chatSettings.publish({
status: 'ready', value: { transcriptView: 'compact' }, revision: 1, writable: true,
status: 'ready', value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 1, writable: true,
})
expect(face.hooks.transcriptView.getSnapshot()).toBe('compact')
await b.runtime.dispose()
})
it('shares the accepted performance preference with settings, composer, and turn tails', async () => {
const b = await bench()
const row = b.runtime.slots.entries('settings.general.item').find(entry => entry.options.id === 'performance-usage')!
const face = (row.inject as unknown as () => PerformanceUsageRowInjected)()
expect(face.hooks.performanceUsage.getSnapshot()).toBe('detailed')
face.setPerformanceUsage('compact')
expect(b.chatSettings.set).toHaveBeenCalledWith('performanceUsage', 'compact')
b.chatSettings.publish({ value: { 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.chat.node').find(entry => entry.options.key === 'turn-tail')!,
]) {
const injected = (entry.inject as unknown as () => Pick<PerformanceUsageRowInjected, 'hooks'>)()
expect(injected.hooks.performanceUsage).toBe(face.hooks.performanceUsage)
}
await b.runtime.dispose()
})
it('shares one Chat store while keeping it distinct from Conversation state', async () => {
const b = await bench()
const conversationStore = storeOf(b.runtime, 'conversation.session')
@@ -1064,6 +1064,7 @@ describe('small branch tails', () => {
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsPills
usePerformanceUsage={selector => selector('detailed')}
t={t}
useChat={bindSnapshotSelector(source)}
useProjection={(key: string) => key === 'tokenUsage'
@@ -21,10 +21,13 @@ describe('ui-chat Host settings', () => {
await fiber.await()
const ns = CHAT_SETTINGS_NAMESPACE
expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE })
expect(ctx.settings.get(ns)).toEqual({ transcriptView: DEFAULT_TRANSCRIPT_VIEW_MODE, performanceUsage: 'detailed' })
await ctx.settings.update(ns, { transcriptView: 'normal' })
expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal' })
expect(ctx.settings.get(ns)).toEqual({ transcriptView: 'normal', performanceUsage: 'detailed' })
await expect(ctx.settings.update(ns, { transcriptView: 'dense' })).rejects.toThrow()
await ctx.settings.update(ns, { performanceUsage: 'compact' })
expect(ctx.settings.get(ns)).toMatchObject({ performanceUsage: 'compact' })
await expect(ctx.settings.update(ns, { performanceUsage: 'hidden' })).rejects.toThrow()
await fiber.dispose()
expect(ctx.settings.describe().map(row => row.ns)).not.toContain(ns)
@@ -8,7 +8,6 @@ import type {
ConversationLocationDataSource, ConversationLocationDataStore, ConversationTurnDataMap, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
import { deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
import {
sameTurnNavigationItem, turnNavigationItem,
} from '../src/client/conversation-nodes/turn-navigation.ts'
@@ -461,7 +460,6 @@ export function chatSnapshotFixture(input: {
return (location.kind === 'turn' || location.kind === 'step')
&& location.turn.turn === turnNumber
})
const metrics = deriveTurnMetrics(legacy.nodes).get(turnNumber)
const tokenUsage = input.turnUsages?.get(turnNumber)
const tailData = {
turn: turnNumber,
@@ -471,8 +469,6 @@ export function chatSnapshotFixture(input: {
branchUnavailable: closing === null
|| preceding?.kind !== 'assistant-step'
|| (preceding.data as ReturnType<typeof assistantData>).finalNode.seq !== closing.finalNode.seq,
...metrics?.ttftMs === undefined ? {} : { ttftMs: metrics.ttftMs },
...metrics?.tokensPerSecond === undefined ? {} : { tokensPerSecond: metrics.tokensPerSecond },
...tokenUsage === undefined ? {} : { tokenUsage },
}
dataStore.set('turn-tail', tailData)
@@ -145,7 +145,7 @@ describe('StatsPills', () => {
source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsPillsProps {
return { useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
return { usePerformanceUsage: selector => selector('detailed'), useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) {
@@ -158,6 +158,27 @@ describe('StatsPills', () => {
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
})
it('compact keeps only speed and cache hit, with no interactive statistics', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} usePerformanceUsage={selector => selector('compact')} />)
expect(view.container.textContent).toBe('20 tok/sCache hit 90%')
expect(view.queryByRole('button')).toBeNull()
fireEvent.mouseOver(view.getByText('20 tok/s'))
expect(view.queryByRole('dialog')).toBeNull()
view.rerender(<StatsPills {...props(source)} />)
expect(view.getAllByRole('button')).toHaveLength(2)
fireEvent.click(view.getAllByRole('button')[0]!)
expect(view.getByRole('dialog')).toBeTruthy()
view.rerender(<StatsPills {...props(source)} usePerformanceUsage={selector => selector('compact')} />)
expect(view.queryByRole('dialog')).toBeNull()
})
it('compact omits unavailable metrics instead of showing counts', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsPills {...props(source, {})} usePerformanceUsage={selector => selector('compact')} />)
expect(view.container.textContent).toBe('')
})
it('renders the counts reading and usage pill and hides a brand-new empty session', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsPills {...props(source)} />)
@@ -275,6 +275,7 @@ function makeHarness(
// Rows and the harness must observe the same chat-store instance.
const chat = createChatStore().create()
const transcriptView = createSnapshotStore<TranscriptViewMode>('compact')
const performanceUsage = createSnapshotStore<'compact' | 'detailed'>('detailed')
const t = makeTranslate(zh, commonZh)
const toolOwners: Array<{
callId: string
@@ -334,6 +335,7 @@ function makeHarness(
case 'turn-tail':
return (
<TurnTailNodeView
usePerformanceUsage={bindSnapshotSelector(performanceUsage)}
{...nodeProps<'turn-tail'>()}
renderSlot={renderTurnTailSlot}
SessionProvider={props.SessionProvider}
@@ -442,6 +444,7 @@ function makeHarness(
openFile, openSkill, loadOlder, loadThrough, openView,
setOutline: (value: unknown) => { outlineValue = value },
chatScroll, forkAt, toolOwners,
setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) },
setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) },
setNodeRenderer: (renderer: React.ComponentProps<typeof ChatNodeSeat>['renderSlot']) => {
nodeSlotOverride = renderer
@@ -1896,7 +1899,7 @@ describe('ChatView', () => {
expect(view.getAllByRole('button', { name: '复制' })).toHaveLength(4)
})
it('the actions-owning assistant footer shows the turn run time', () => {
it('the assistant footer omits turn run time', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'), // time 1_000
@@ -1909,10 +1912,10 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
// The exact turn/end includes trailing tool activity after the final text.
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).toContain('用时 19秒')
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).not.toContain('用时 19秒')
})
it('the actions-owning assistant footer shows an hour-scale run time', () => {
it('the assistant footer omits hour-scale run time', () => {
const h = makeHarness({
nodes: [
user(1, 'hi'),
@@ -1925,10 +1928,10 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent)
.toContain('用时 1小时05分03秒')
.not.toContain('用时 1小时05分03秒')
})
it('the settled footer exposes ttft, decode throughput, and usage as the details trigger', () => {
it('the settled footer shows usage only in Detailed mode', () => {
const first: AssistantMessageNode = {
kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, blocks: [{ kind: 'text', text: 'mid' }],
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
@@ -1962,17 +1965,11 @@ describe('ChatView', () => {
expect(dialog.textContent).toContain('缓存命中49.4%')
expect(dialog.textContent).toContain('未缓存输入5,060 tok')
fireEvent.keyDown(document, { key: 'Escape' })
// The time pill carries the run time; first-step ttft (1.2s) and 100
// tokens over 5s of decode move into its dialog.
const timeTrigger = view.getByRole('button', { name: /用时 19秒/ })
expect(timeTrigger.textContent).toBe('用时 19秒')
expect(view.queryByText(/速度 20 tok\/s|首 token/)).toBeNull()
fireEvent.click(timeTrigger)
const timeDialog = view.getByRole('dialog')
expect(timeDialog.getAttribute('aria-label')).toBe('本轮用时和速度')
expect(timeDialog.textContent).toContain('本轮总用时19秒')
expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s')
expect(timeDialog.textContent).toContain('首 token 用时(TTFT)1.2秒')
expect(view.queryByRole('button', { name: /用时/ })).toBeNull()
act(() => { h.setPerformanceUsage('compact') })
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
act(() => { h.setPerformanceUsage('detailed') })
expect(view.getByRole('button', { name: /用量/ })).toBeTruthy()
})
it('withholds the usage-details trigger when turn usage is outside the window', () => {
@@ -1987,9 +1984,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 20]]),
})
const view = render(<h.ChatView {...h.props} />)
// Timing facts keep their pill, but with no usage in the window there is
// no usage pill to click.
expect(view.getByRole('button', { name: /用时/ })).toBeTruthy()
expect(view.queryByRole('button', { name: /用时/ })).toBeNull()
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
})
@@ -987,8 +987,6 @@ describe('built-in conversation node Definitions', () => {
})
const tail = node(view, 'turn-tail')?.data as TurnTailChatData
expect(tail.turn).toBe(1)
expect(tail.ttftMs).toBeUndefined()
expect(tail.tokensPerSecond).toBeUndefined()
}
})
@@ -46,6 +46,7 @@ describe('render branch tails', () => {
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsPills
usePerformanceUsage={selector => selector('detailed')}
t={t}
useChat={bindSnapshotSelector(source)}
useProjection={() => undefined}
@@ -0,0 +1,30 @@
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime'
import type { ChatSettings } from '../src/chat-settings.ts'
import { PerformanceUsagePolicy } from '../src/client/performance-usage.ts'
describe('PerformanceUsagePolicy', () => {
it('keeps explicit choices live when a memory-only scope cannot persist them', () => {
const host = stubSettingsScope<ChatSettings>()
host.publish({ mode: 'memory', status: 'ready' })
const policy = new PerformanceUsagePolicy(host.scope)
expect(policy.mode.getSnapshot()).toBe('detailed')
policy.setMode('compact')
expect(policy.mode.getSnapshot()).toBe('compact')
expect(host.set).toHaveBeenCalledWith('performanceUsage', 'compact')
host.publish({ value: undefined, writable: false })
expect(policy.mode.getSnapshot()).toBe('compact')
policy.setMode('compact')
expect(host.set).toHaveBeenCalledTimes(1)
})
it('adopts accepted Host settings at construction and after updates', () => {
const host = stubSettingsScope<ChatSettings>()
host.publish({ value: { transcriptView: 'normal', performanceUsage: 'compact' } })
const policy = new PerformanceUsagePolicy(host.scope)
expect(policy.mode.getSnapshot()).toBe('compact')
host.publish({ value: { transcriptView: 'normal', performanceUsage: 'detailed' } })
expect(policy.mode.getSnapshot()).toBe('detailed')
})
})
@@ -30,18 +30,18 @@ describe('TranscriptViewPolicy', () => {
const host = stubSettingsScope<ChatSettings>()
const policy = new TranscriptViewPolicy(host.scope)
host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true })
host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(policy.mode.getSnapshot()).toBe('normal')
policy.setMode('normal')
expect(host.set).not.toHaveBeenCalled()
host.publish({ value: { transcriptView: 'compact' }, revision: 2 })
host.publish({ value: { transcriptView: 'compact', performanceUsage: 'detailed' }, revision: 2 })
expect(policy.mode.getSnapshot()).toBe('compact')
})
it('adopts an accepted section standing at construction', () => {
const host = stubSettingsScope<ChatSettings>()
host.publish({ status: 'ready', value: { transcriptView: 'normal' }, revision: 1, writable: true })
host.publish({ status: 'ready', value: { transcriptView: 'normal', performanceUsage: 'detailed' }, revision: 1, writable: true })
expect(new TranscriptViewPolicy(host.scope).mode.getSnapshot()).toBe('normal')
})
})
@@ -8,6 +8,8 @@ import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { TranscriptViewRow, type TranscriptViewRowProps } from '../src/client/settings/TranscriptViewRow.tsx'
import { PerformanceUsageRow } from '../src/client/settings/PerformanceUsageRow.tsx'
import type { PerformanceUsageMode } from '../src/chat-settings.ts'
import { en, zh } from '../src/client/locale.ts'
afterEach(cleanup)
@@ -46,7 +48,7 @@ function mount(mode: 'normal' | 'compact' = 'compact', dictionary: typeof en | t
t: makeTranslate(dictionary),
}
render(<TranscriptViewRow {...props} />)
return { setTranscriptView }
return { setTranscriptView, props }
}
describe('TranscriptViewRow', () => {
@@ -76,3 +78,23 @@ describe('TranscriptViewRow', () => {
expect(screen.getByRole('button', { name: '标准' })).toBeDefined()
})
})
describe('PerformanceUsageRow', () => {
it('selects compact statistics independently of conversation display', () => {
const b = mount()
const source = createSnapshotStore<PerformanceUsageMode>('detailed')
const setPerformanceUsage = vi.fn((mode: PerformanceUsageMode) => { source.set(mode) })
render(<PerformanceUsageRow
{...b.props}
usePerformanceUsage={bindSnapshotSelector(source)}
setPerformanceUsage={setPerformanceUsage}
/>)
expect(screen.getByText('Performance & usage')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: 'Detailed' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Compact' }))
expect(setPerformanceUsage).toHaveBeenCalledWith('compact')
expect(screen.getAllByRole('button', { name: 'Compact' })).toHaveLength(2)
expect(b.setTranscriptView).not.toHaveBeenCalled()
})
})
@@ -1,11 +1,11 @@
// Per-turn latency/throughput fold and the footer figure formatters.
// Assistant timing readings and statistics formatting.
import { describe, expect, it } from 'vitest'
import type {
AssistantMessageNode, ConversationNode, UserMessageNode,
AssistantMessageNode,
} from '@deepseek-ai/dsh-client-ui-chat/client'
import { assistantStepReading, deriveTurnMetrics } from '../src/client/contract/turn-metrics.ts'
import { formatLatencySeconds, formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
import { assistantStepReading } from '../src/client/contract/turn-metrics.ts'
import { formatTokensPerSecond } from '../src/client/chat/message-chrome.ts'
import { formatCacheHitPercent } from '../src/client/chat/token-format.ts'
interface StepSpec {
@@ -22,10 +22,6 @@ const assistant = ({ seq, turn, step, timing, usage }: StepSpec): AssistantMessa
...(usage === undefined ? {} : { usage }),
})
const user = (seq: number): UserMessageNode => ({
kind: 'user', seq, time: seq * 1_000, content: [{ type: 'text', text: 'hi' }] as never, source: null,
})
describe('assistantStepReading', () => {
it('derives ttft, decode time, and output tokens from a fully recorded step', () => {
const reading = assistantStepReading(assistant({
@@ -66,92 +62,11 @@ describe('assistantStepReading', () => {
})
})
describe('deriveTurnMetrics', () => {
it('takes ttft from the lowest step and throughput over all sampled steps', () => {
const nodes: ConversationNode[] = [
user(1),
// Out of step order on purpose: the lowest step owns the ttft slot.
assistant({
seq: 4, turn: 1, step: 2,
timing: { stepStartTime: 10_000, firstTokenTime: 10_200, completedTime: 12_200 },
usage: { outputTokens: 60 },
}),
assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 2_200, completedTime: 5_200 },
usage: { outputTokens: 40 },
}),
]
// 100 tokens over 5s of decode.
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 1_200, tokensPerSecond: 20 })
})
it('emits ttft without throughput when no step carries usage', () => {
const nodes = [assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_900, completedTime: 3_000 },
})]
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ ttftMs: 900 })
})
it('emits throughput without ttft when only a later step is recorded', () => {
const nodes = [
assistant({ seq: 2, turn: 1, step: 1 }),
assistant({
seq: 4, turn: 1, step: 2,
timing: { stepStartTime: 10_000, firstTokenTime: 10_500, completedTime: 12_500 },
usage: { outputTokens: 30 },
}),
]
expect(deriveTurnMetrics(nodes).get(1)).toEqual({ tokensPerSecond: 15 })
})
it('omits turns with no readings and zero-decode throughput', () => {
const nodes = [
assistant({ seq: 2, turn: 1, step: 1 }),
assistant({
seq: 4, turn: 2, step: 1,
timing: { stepStartTime: null, firstTokenTime: 5_000, completedTime: 5_000 },
usage: { outputTokens: 10 },
}),
]
expect(deriveTurnMetrics(nodes).size).toBe(0)
})
it('keeps turns independent and ignores non-assistant nodes', () => {
const nodes: ConversationNode[] = [
user(1),
assistant({
seq: 2, turn: 1, step: 1,
timing: { stepStartTime: 1_000, firstTokenTime: 1_400, completedTime: 2_400 },
usage: { outputTokens: 10 },
}),
user(3),
assistant({
seq: 4, turn: 2, step: 1,
timing: { stepStartTime: 4_000, firstTokenTime: 4_100, completedTime: 6_100 },
usage: { outputTokens: 100 },
}),
]
const metrics = deriveTurnMetrics(nodes)
expect(metrics.get(1)).toEqual({ ttftMs: 400, tokensPerSecond: 10 })
expect(metrics.get(2)).toEqual({ ttftMs: 100, tokensPerSecond: 50 })
})
})
describe('footer figure formatters', () => {
it('omits a redundant decimal zero in cache-hit percentages', () => {
expect(formatCacheHitPercent(1, 2, 1)).toBe('50')
})
it('formats latency with one decimal under ten seconds and whole seconds beyond', () => {
expect(formatLatencySeconds(840)).toBe('0.8')
expect(formatLatencySeconds(1_000)).toBe('1')
expect(formatLatencySeconds(9_949)).toBe('9.9')
expect(formatLatencySeconds(12_400)).toBe('12')
expect(formatLatencySeconds(-5)).toBe('0')
})
it('formats throughput with whole tokens from ten up and one decimal below', () => {
expect(formatTokensPerSecond(34.4)).toBe('34')
expect(formatTokensPerSecond(9.96)).toBe('10')
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import { cleanup, fireEvent, render } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { TurnTimePanel, TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx'
import { TurnUsagePanel } from '../src/client/chat/TurnUsagePanel.tsx'
import type { TurnTokenUsage } from '../src/client/contract/chat-nodes.ts'
import { en } from '../src/client/locale.ts'
@@ -96,38 +96,3 @@ describe('TurnUsagePanel', () => {
expect(view.queryByRole('dialog')).toBeNull()
})
})
describe('TurnTimePanel', () => {
it('shows a clock-and-duration pill and opens the time dialog on click', () => {
const view = render(
<TurnTimePanel runMs={19_000} tokensPerSecond={20} ttftMs={1_200} t={t} />,
)
const trigger = view.getByRole('button')
expect(trigger.textContent).toBe('Ran for 19s')
expect(trigger.querySelector('svg')).not.toBeNull()
expect(trigger.getAttribute('aria-haspopup')).toBe('dialog')
expect(view.queryByRole('dialog')).toBeNull()
fireEvent.click(trigger)
expect(trigger.getAttribute('aria-expanded')).toBe('true')
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('Turn time and speed')
expect(dialog.parentElement).toBe(document.body)
const details = dialog.querySelector('[data-turn-time-details]') as HTMLElement
expect(details.textContent).toContain('Total run time19s')
expect(details.textContent).toContain('Tokens per second (TPS)20 tok/s')
expect(details.textContent).toContain('Time to first token (TTFT)1.2s')
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.queryByRole('dialog')).toBeNull()
})
it('omits unrecorded speed and TTFT rows', () => {
const view = render(<TurnTimePanel runMs={3_000} t={t} />)
fireEvent.click(view.getByRole('button'))
const dialog = view.getByRole('dialog')
expect(dialog.textContent).toContain('Total run time3s')
expect(dialog.textContent).not.toContain('Tokens per second')
expect(dialog.textContent).not.toContain('Time to first token')
})
})
@@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:219',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:227',
},
{
key: 'conversation.chat.commandview',
@@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:207',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
},
{
key: 'conversation.chat.node',
@@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'<one key the owner dispatches>\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:188',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
},
{
key: 'conversation.chat.turnTail',
@@ -354,7 +354,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:213',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:221',
},
{
key: 'conversation.composer',
@@ -1128,7 +1128,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:201',
source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
},
{
key: 'conversation.plan-review.actions',
@@ -1991,6 +1991,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
declaredBy: 'an entry in \'settings.section\' (client-ui-settings-general), so it exists while that entry is mounted',
occupants: [
'client-locale LanguageRow id \'language\'',
'client-ui-chat PerformanceUsageRow id \'performance-usage\'',
'client-ui-chat TranscriptViewRow id \'transcript-view\'',
'client-ui-conversation EnterBehaviorRow id \'composer-enter\'',
'client-ui-permission-presets PermissionRow id \'permission\'',
@@ -99,5 +99,4 @@
- button "有问题的回答"
- button "在新对话中分支"
- button "用量 36.2K tok"
- button "用时 {{duration}}"
- text: {{clock}}
@@ -18,7 +18,6 @@
- button "有问题的回答"
- button "在新对话中分支"
- button "用量 8.2K tok"
- button "用时 {{duration}}"
- text: {{clock}}
- textbox "发消息或创建任务, / 调用指令, @ 文件或对话"
- button "添加文件或调用指令"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."'
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -16,7 +16,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."'
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."'
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -16,7 +16,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- 'button "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."'
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -20,7 +20,6 @@
- button "Bad response"
- button "Branch into a new conversation"
- button "Usage 17.3K tok"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -20,7 +20,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -16,7 +16,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -42,7 +42,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "System prompt"
- button "5 tool calls · 2 messages" [expanded]
@@ -124,7 +123,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "Back to bottom"
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -28,7 +28,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "System prompt"
- button "5 tool calls · 2 messages"
@@ -85,7 +84,6 @@
- button "Bad response"
- button "Branch into a new conversation"
- tooltip "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -16,7 +16,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -17,7 +17,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -20,7 +20,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -16,7 +16,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -0,0 +1 @@
- text: {{throughput}} tok/s Cache hit 98%
+1 -5
View File
@@ -20,9 +20,7 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation" [disabled]
- text: Available only on the last message of a completed turn
- button "Ran for {{duration}}"
- text: {{clock}}
- text: Available only on the last message of a completed turn {{clock}}
- button "Read a.txt":
- text: Read
- button "a.txt"
@@ -37,7 +35,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}} Keep this later input in the original conversation. {{clock}}
- button "Copy"
- paragraph: ORIGINAL ONLY
@@ -45,7 +42,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -19,7 +19,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -26,7 +26,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
-1
View File
@@ -44,5 +44,4 @@
- button "有问题的回答"
- button "在新对话中分支"
- button "用量 32K tok"
- button "用时 {{duration}}"
- text: {{clock}}
-1
View File
@@ -51,7 +51,6 @@
- button "Bad response"
- button "Branch into a new conversation"
- button "Usage 19K tok"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
-1
View File
@@ -27,7 +27,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -23,7 +23,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -16,7 +16,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "2 queued messages" [expanded]
- list:
@@ -17,7 +17,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "2 queued messages" [expanded]
- list:
@@ -21,7 +21,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "queued.png, click to view original":
- img "queued.png"
@@ -33,7 +32,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}} Continue with the queued comparison {{clock}}
- button "Copy"
- button "Thought for a while"
@@ -42,7 +40,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -26,7 +26,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md"
@@ -26,7 +26,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md"
@@ -26,7 +26,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "compact Compacted 5 history items (~{{tokens}} tokens)"
- button "Context injection AGENTS.md"
@@ -22,7 +22,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{date}} {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -20,7 +20,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -18,7 +18,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -7,7 +7,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -24,7 +24,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "System prompt"
- text: Now give the same explanation to a human reader. {{clock}}
@@ -36,7 +35,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -22,7 +22,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- button "System prompt"
- text: Now give the same explanation to a human reader. {{clock}}
@@ -33,7 +32,6 @@
- button "Good response"
- button "Bad response"
- button "Branch into a new conversation"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -17,7 +17,6 @@
- button "Bad response"
- button "Branch into a new conversation"
- button "Usage 15.8K tok"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
@@ -21,7 +21,6 @@
- button "Bad response"
- button "Branch into a new conversation"
- button "Usage 15.8K tok"
- button "Ran for {{duration}}"
- text: {{clock}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more