Merge pull request #3699 from deepseek-harness/fix/input-placeholder-stat-ui-polish

fix(web): composer polish — menu layering, placeholders, spacing, and two-pill session stats
This commit is contained in:
Yifffan authored and GitHub committed 2026-09-08 05:35:20 +02:00
commit 012475f7ae
128 files changed
+1584 -787

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/architecture/2026-07-29-projected-token-usage-and-request-context.md
2026-07-29-projected-token-usage-and-request-context.md: d62f7dccd544a342da64fc35c24d32d53bf56231
2026-07-29-projected-token-usage-and-request-context.zh.md: 6d2bb624ac11dbcdac30695913d3c16513bfdf9e
2026-07-29-projected-token-usage-and-request-context.md: f96257243bef91ff6a73418231de5e777d8edb2e
2026-07-29-projected-token-usage-and-request-context.zh.md: 7365d5d816f9f9b324f3e3d3b4db0d3346851bdf
@@ -26,7 +26,7 @@ Capacity deliberately stays out of `EpochHeader`. That type is the reconstructio
Both units ride the standard projection lifecycle: history tail baselines, `session/projection` live frames, higher-seq-wins client storage, JSON checkpoints, cache recovery, and unit unload. There is no token-specific history field, mux frame, projector, revision counter, or client fence.
The Web `StatsLine` reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times — those answer "what is on screen" and are correctly window-scoped. Durable token and context groups remain when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the token groups; occupancy stays hidden until both pressure and capacity are known. The exact-overflow tooltip mounts its measuring child only for a non-empty line and retains one `ResizeObserver` while values change; text changes perform one direct measurement without replacing the observer.
The Web [`StatsPills`](../feature/2026-09-07-composer-session-stats-pills.md) reads both through the standard `useProjection` seat. Window nodes still supply turn and step counts plus LLM and tool wall times as the no-projection fallback — those answer "what is on screen" and are correctly window-scoped. The durable usage pill remains when compaction leaves no visible assistant step. Cache writes count in billed input and in the cache-hit denominator. A deployment without token-meter drops the usage pill; context occupancy lives on the composer's ContextMeter ring. Exact token figures show in the usage pill's click-open dialog rather than a hover tooltip.
## Context occupancy is approximate, and that is the decision
@@ -48,7 +48,7 @@ That cost bought a worse display: occupancy went blank after every reconnect and
**Resolve capacity inside token-meter.** The package documents itself as independent of model routing and is otherwise a pure reader that never appends to the log. AgentLoop already holds the resolved metadata where the header is written.
**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but `StatsLine` lives in `ui-conversation` while the model directory lives in `ui-model-selection`, and `ui-conversation` cannot depend on `ui-model-selection`. Delivering it would have required either a second dock entry splitting one text row across two plugins, or a cross-plugin store write.
**Extend the `session.models` RPC with capacity.** The handler already resolves and discards it, so the field is nearly free — but the stats display (now `StatsPills`, ui-chat) and the model directory live in separate plugins with no dependency between them. Delivering it would have required either a second dock entry splitting one surface across two plugins, or a cross-plugin store write.
**Add a context circle beside the model selector.** That placement suggests selected-model state. The stats line carries the figure without a duplicate UI or data path.
@@ -26,7 +26,7 @@ token-meter 还拥有在持久事件上运行的共享纯 attempt/Turn fold。
两个单元都沿用标准投影生命周期:历史尾页基线、`session/projection` 实时帧、seq 高者胜的客户端存储、JSON 检查点、缓存恢复和单元卸载。系统没有任何 token 专用的历史字段、mux 帧、投影器、修订计数器或客户端栅栏。
Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节点仍提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久 token 与上下文分组仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉 token 分组;只有压力与容量都已知时才显示占用率。精确 overflow tooltip 只在统计行非空时挂载测量子组件,并在值变化期间保留同一个 `ResizeObserver`;文本变化只直接测量一次,不替换 observer。
Web [`StatsPills`](../feature/2026-09-07-composer-session-stats-pills.zh.md) 通过标准 `useProjection` 席位读取两者。窗口内节点仍作为无投影回退提供轮次和步骤计数,以及 LLM(大语言模型)与工具的墙钟时间:它们回答的是「屏幕上有什么」,按窗口作用域正是正确的。压缩使可见 assistant 步骤归零后,持久用量 pill 仍会保留。缓存写入会计入计费输入和缓存命中率分母。未部署 token-meter 时会去掉用量 pill;上下文占用率由输入框旁的 ContextMeter 圆环承载。精确 token 数字显示在用量 pill 点击展开的弹层里,而非悬停提示。
## 上下文占用率是近似值,而这正是决策本身
@@ -48,7 +48,7 @@ Web `StatsLine` 通过标准 `useProjection` 席位读取两者。窗口内节
**在 token-meter 内部解析容量。** 该包自述与模型路由无关,且在其他方面是一个从不向日志追加内容的纯读取方。AgentLoop 在写入请求头的位置已经持有已解析的元数据。
**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但 `StatsLine` 位于 `ui-conversation`,模型目录位于 `ui-model-selection`,而 `ui-conversation` 不能依赖 `ui-model-selection`。要送达它,就得增加第二个 dock 条目、把一行文本拆到两个插件里,或者做一次跨插件的 store 写入。
**为 `session.models` RPC 增加容量字段。** 其处理器已经解析出容量又将其丢弃,因此这个字段几乎是免费的;但统计展示(现为 `StatsPills`,ui-chat)与模型目录位于两个互不依赖的插件。要送达它,就得增加第二个 dock 条目把一个表面拆到两个插件里,或者做一次跨插件的 store 写入。
**在模型选择器旁增加上下文圆环。** 该位置会让人以为这是所选模型的状态。统计行可以承载该数字,无需引入重复的 UI 或数据路径。
@@ -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/architecture/2026-08-09-client-conversation-node-assembly.md
2026-08-09-client-conversation-node-assembly.md: 7f5b948f9083b20edd037fe32c55df7ba3378692
2026-08-09-client-conversation-node-assembly.zh.md: 8716b25584a8cefd8d2239f242d430775ed2a8ef
2026-08-09-client-conversation-node-assembly.md: 842dfb0218478591f975c97064f101a35ea2f211
2026-08-09-client-conversation-node-assembly.zh.md: 6ab64c25957d33487461fe56e122faedb19f9421
@@ -315,7 +315,7 @@ The shell synchronously resolves the persisted selection when a Session binding
Ordinary prepend and append flushes call `apply({ upserts, timeline })` only for active targets. Complete window replacement and Registry rebuild call `replace()` only for active targets. Unsubscription does not remove a target, so returning to an opened View does not rebuild it.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsLine and mirrored into top-level public compatibility fields.
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) maintains `order`, a keyed `nodes` store with identity-stable Node and Turn-process sources, the turn/step `locations` index, `timeline`, and the `legacy` slice used by StatsPills and mirrored into top-level public compatibility fields.
Only a new key or a change to `anchorSeq`, visibility, or Location identity makes a Chat update structural. An ordinary content change does not rebuild `order`; the keyed Node store replaces that key's value and publishes only its source. The Turn-process projector recalculates cross-Node presentation only for a Turn whose structure, specification, or status changed, then publishes only that Turn's process sources.
@@ -424,4 +424,4 @@ Inbox Context retention grows with splice count and claimed message count rather
The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definitions that consume Assistant deltas also maintain equivalent scalar and packed update branches. Definition authors must understand stable IDs, unique scalar starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal.
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsPills and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session.
@@ -315,7 +315,7 @@ Session binding 可用、缓存的 binding 成为 current 或 View roster 变化
普通 prepend 与 append flush 只对 active target 调用 `apply({ upserts, timeline })`。完整 window replace 与 Registry rebuild 只对 active target 调用 `replace()`。取消订阅不会移除 target,因此返回已打开的 View 不会重建。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsLine 使用并镜像到顶层公共兼容字段的 `legacy` slice。
[`ChatSnapshotBuilder`](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) 维护 `order`、带身份稳定 Node 与 Turn-process source 的 keyed `nodes` store、turn/step `locations` index、`timeline`,以及由 StatsPills 使用并镜像到顶层公共兼容字段的 `legacy` slice。
Chat 结构变化只由新 key、`anchorSeq`、visibility 或 Location identity 变化触发。普通内容变化不重建 `order`;keyed Node store 只替换该 key 的 value 并发布其 source。Turn-process projector 仅为结构、规格或状态发生变化的 Turn 重算跨 Node 呈现,再只发布该 Turn 的 process source。
@@ -424,4 +424,4 @@ Inbox Context 的保留量随 splice 数和已 claim 消息数增长,不再随
代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。消费 Assistant delta 的 Definition 还需要维护等价的 scalar 与 packed update 分支。Definition 作者必须理解稳定 ID、唯一 scalar start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsPills 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。
@@ -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-07-composer-session-stats-pills.md
2026-09-07-composer-session-stats-pills.md: 818c3c156f72816eb6540d505782be8e03dc6aa0
2026-09-07-composer-session-stats-pills.zh.md: 8a74cddd3176026d799fb5b4223a51371ee8e253
@@ -0,0 +1,31 @@
# Agent Note: Composer session stats — two icon pills with click-open stat dialogs
Status: implemented
English | [中文](2026-09-07-composer-session-stats-pills.zh.md)
## Problem
The session stats strip under the composer (`StatsLine`, ui-chat, mounted on `conversation.composer.dock`) rendered every figure as one resident text line: turn/step counts, LLM and tool wall times, TTFT/TPS averages, and compact token totals with cache-hit share. The line crowded as figures accumulated, exact token counts appeared nowhere (the `ResizeObserver`-measured hover tooltip only restated the same compact line when it truncated), and the flat text gave no grouping — time figures and billing figures read as one undifferentiated row. An in-page A/B against a two-pill variant settled the direction: the pills won on scannability and on giving each figure family a home.
## Decision
`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 `IconGaugeOutline16`, 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 (`IconDatabaseOutline16`) shows the compact billed total plus cache-hit share and click-opens the Token 用量 dialog (cache hit, uncached input, cache read, cache write, output — 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.
- **Data sourcing is unchanged in architecture.** Counts and times prefer the durable `sessionStats` projection with the window fold as the assembly-without-the-unit fallback ([whole-session counts](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md)); token figures ride `tokenUsage` only, so an absent projection drops the usage pill rather than showing window-derived billing. Cache writes stay in the billed total and the cache-hit denominator ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). Context occupancy stays on the composer's ContextMeter ring, where it already lived beside `StatsLine` — the strip never carried it.
- **Render discipline.** The row folds settled nodes only (`chat.legacy.nodes` identity), so streaming chunk frames cause zero rerenders — pinned by a render-count unit test. A session with no closed step and no billed tokens renders nothing.
- **`data-composer-stats` is a cross-package attribute contract.** The pills' root carries it; ui-conversation's `InputBar.module.css` `:has([data-composer-stats])` rule tightens the composer's bottom clearance to 4px when the row is mounted. The producer side pins the attribute in unit tests, following the `data-trigger-menu` precedent.
## Alternatives considered
- **The single-line variant (StatsLine, the A/B loser).** All figures resident in one text row, with a hover tooltip restating the full line when it truncated. Lost on crowding and reach: exact token counts appeared nowhere (the line and its tooltip both carried compact totals only), and one row gave time and billing figures no visual grouping.
- **Three resident groups with one shared dialog.** An intermediate iteration kept counts, time, and tokens as three inline groups. Two pills won because the time/usage split matches the two underlying projections one-to-one and each pill's icon telegraphs its dialog.
- **Extracting the dl bucket rows shared with `TurnUsagePanel`.** The session-total dialog and the per-turn panel render the same skin but different contracts (all buckets always present vs optional per-turn fields plus model routes); a shared component would be conditionals around nine lines. The mirror is marked `jscpd:ignore` with the reason inline.
## Consequences
- `ChatSnapshotBuilder`'s legacy slice now serves StatsPills; the [node-assembly note](../architecture/2026-08-09-client-conversation-node-assembly.md) tracks that consumer rename.
- Exact token counts become reachable at all — one click — where `StatsLine` showed only compact totals; the strip itself carries only the two headline readings.
- Web e2e strip assertions match substring text inside the time pill; the fresh-round-trip aria goldens pin the two-pill structure, and stats-paged-history pins the counts reading alone over a log with no billed tokens.
- The `conversation.composer.dock` occupant in the generated slot catalog is `client-ui-chat StatsPills id 'stats'`.
@@ -0,0 +1,31 @@
# Agent Note: 输入框下的会话统计 —— 双图标 pill 与点击展开的统计弹层
Status: implemented
[English](2026-09-07-composer-session-stats-pills.md) | 中文
## 问题
输入框下方的会话统计条(`StatsLine`,ui-chat,挂载于 `conversation.composer.dock`)把所有数字渲染成一整行常驻文本:轮/步计数、模型与工具用时、TTFT/TPS 均值、紧凑 token 总量与缓存命中率。数字越多行越拥挤,精确 token 计数无处可看(带 `ResizeObserver` 溢出测量的悬停提示只在截断时复述同一行紧凑文本),纯文本也没有分组——时间类数字和计费类数字读起来混作一行。一次页面内 A/B 对比双 pill 变体后方向定案:pill 方案在可扫读性和"每类数字有归属"上胜出。
## 决定
`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(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度);日志里没有任何计时数字时弹层会是空的,此时该 pill 渲染为静态读数而非按钮。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、缓存写入、输出——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭、可选的外部持有开合状态);pill 行持有唯一的互斥开合槽位,打开任一弹层即关闭另一个,且每个按钮携带显式 `aria-label`,用 ` · ` 分隔 aria-hidden 分隔符在视觉上连接的两段文本。
- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../../archived/bug-fix/2026-08-12-full-session-turn-step-counts.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用仍在输入框旁的 ContextMeter 圆环上,`StatsLine` 时代它就在那里——统计条从未承载过它。
- **渲染纪律。** 该行只折叠已定稿节点(`chat.legacy.nodes` 身份),流式 chunk 帧零重渲染——由渲染计数单测钉住。无已完成步且无计费 token 的会话什么都不渲染。
- **`data-composer-stats` 是跨包属性契约。** pill 行根元素携带它;ui-conversation 的 `InputBar.module.css` 用 `:has([data-composer-stats])` 在该行挂载时把输入框底部留白收紧到 4px。生产方在单测里钉住该属性,沿用 `data-trigger-menu` 先例。
## 备选方案
- **单行变体(StatsLine,A/B 落选方)。** 全部数字常驻一行文本,悬停提示只在截断时复述整行。败在拥挤与可达性:精确 token 计数无处可看(行内和提示里都只有紧凑总量),单行也无法给时间与计费数字分组。
- **三个常驻分组共享一个弹层。** 中间迭代曾把计数、时间、token 保持为三个内联分组。双 pill 胜出是因为时间/用量的切分与两个底层投影一一对应,且每个 pill 的图标直接预告其弹层内容。
- **抽出与 `TurnUsagePanel` 共享的 dl 分桶行。** 会话总量弹层与逐轮面板皮肤相同但契约不同(此处所有分桶恒在;逐轮字段可选且含模型路由);共享组件只是给九行代码包一层条件。该镜像以 `jscpd:ignore` 标注并内联说明理由。
## 后果
- `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。
- 精确 token 计数第一次变得可达——一次点击即可;`StatsLine` 只展示过紧凑总量。统计条本身只承载两个头条读数。
- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;fresh-round-trip 的 aria golden 钉住双 pill 结构,stats-paged-history 则在无计费 token 的日志上钉住单独的计数读数。
- 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。
+1 -1
View File
@@ -112,7 +112,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
fireEvent.click(contextTrigger)
const contextPanel = await screen.findByRole('dialog', { name: 'of context used' })
within(contextPanel).getByText('System prompt')
within(contextPanel).getByText('Tools')
within(contextPanel).getByText('Tool definitions')
within(contextPanel).getByText('Messages')
// The write/edit turns render a real diff card through the assembled graph
@@ -21,7 +21,7 @@ async function freshComposer(): Promise<HTMLElement> {
fireEvent.click(start)
return await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -192,7 +192,7 @@
- text: {{clock}}
- button "Back to bottom":
- img
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -202,4 +202,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}}
- button "1 turns 2 steps":
- img
- text: 1 turns 2 steps
@@ -50,7 +50,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -60,4 +60,6 @@
- text: github-webhook-review-test/reply
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -42,7 +42,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -52,4 +52,6 @@
- text: github-webhook-review-test/reply
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -16,7 +16,7 @@
- img
- img
- text: "goal No goal is currently set. Usage: /goal [<objective>|clear|edit <objective>|pause|resume]"
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -50,7 +50,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -60,4 +60,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -29,7 +29,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -39,4 +39,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -41,7 +41,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -51,4 +51,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -45,7 +45,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -55,4 +55,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -15,7 +15,7 @@
- button "Session recall Research notes":
- img
- text: Session recall Research notes
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -42,7 +42,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -53,4 +53,9 @@
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "272 tok · Cache hit 0%":
- img
- text: 272 tokCache hit 0%
@@ -34,7 +34,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -45,4 +45,9 @@
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 256 tok · Output 16 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "272 tok · Cache hit 0%":
- img
- text: 272 tokCache hit 0%
@@ -461,7 +461,7 @@
- text: 7/25 {{clock}}
- button "Back to bottom":
- img
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -471,4 +471,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 28 turns · 28 steps LLM {{duration}}
- button "28 turns 28 steps":
- img
- text: 28 turns 28 steps
@@ -33,7 +33,7 @@
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -52,7 +52,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -63,4 +63,9 @@
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok
- button "1 turns 2 steps · {{throughput}} tok/s":
- img
- text: 1 turns 2 steps{{throughput}} tok/s
- button "40 tok · Cache hit 0%":
- img
- text: 40 tokCache hit 0%
@@ -40,7 +40,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -51,4 +51,9 @@
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 20 tok · Output 20 tok
- button "1 turns 2 steps · {{throughput}} tok/s":
- img
- text: 1 turns 2 steps{{throughput}} tok/s
- button "40 tok · Cache hit 0%":
- img
- text: 40 tokCache hit 0%
+1 -1
View File
@@ -267,7 +267,7 @@ describe.each(MODE === 'record' ? ['deepseek-official'] : ['deepseek-official',
await selectModel('DeepSeek-V4-Flash')
expect(captured()).toHaveLength(releasedCount)
await page.getByRole('button', { name: 'New session', exact: true }).last().click()
const input = page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
const input = page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
await input.waitFor({ timeout: 15_000 })
await input.fill('/feedback Feedback before any model request.')
expect(captured()).toHaveLength(releasedCount)
+1 -1
View File
@@ -45,7 +45,7 @@ describe('web e2e: goal bar clear convergence', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-goal-bar-clear'))
// Startup reuses the fixture workspace's blank session, keeping this
// command independent of alpha's running replay and pending question.
const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
const input = page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
await input.waitFor({ timeout: 10_000 })
await input.fill('/goal guard rapid clear clicks')
await input.press('Enter')
+3 -3
View File
@@ -90,7 +90,7 @@ it('accepts pasted images into the composer rail in order and removes them', asy
// this assembled lane pins the intake chain over the built graph.
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -161,7 +161,7 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov
fireEvent.click(start)
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -216,7 +216,7 @@ it('renders a host dimension rejection with the projected 2000px limit', async (
const textarea = await waitFor(() => {
const surface = document.querySelector<HTMLElement>(
'[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]',
'[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
+2 -1
View File
@@ -222,7 +222,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.locator('[role="treeitem"][aria-selected="true"]').count(), { timeout: 10_000 }).toBe(1)
await expect.poll(() => page.getByText('LIGHTHOUSE', { exact: true }).count(), { timeout: 15_000 }).toBeGreaterThanOrEqual(1)
await expect.poll(() => page.getByText('Cache hit 99.5%', { exact: true }).count(), { timeout: 15_000 }).toBe(1)
// The usage pill's one label span concatenates the billed total and the cache-hit share.
await expect.poll(() => page.getByRole('button', { name: /Cache hit 99\.5%/ }).count(), { timeout: 15_000 }).toBe(1)
// Host: the session's durable header cwd is the folder the workspace
// flow created and adopted (<workspaceCwd>/workspace) — the proof the
// send went through workspace materialization rather than a bare
+1 -1
View File
@@ -123,7 +123,7 @@ describe('web e2e: settled Markdown math rendering', () => {
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
expect(await page.locator('.katex-error').count()).toBe(0)
await expect.poll(
() => page.getByText('1 turns · 1 steps', { exact: false }).count(),
() => page.getByText('1 turns 1 steps', { exact: false }).count(),
{ timeout: 10_000 },
).toBe(1)
+4
View File
@@ -97,6 +97,10 @@ describe('web e2e: plan review takeover round trip', () => {
}
await card.getByRole('button', { name: 'Approve' }).click()
// Park the pointer: the card unmounts and the ContextMeter ring lands
// under the click position, whose 200ms hover delay would arm a tooltip
// into the aria captures below.
await page.mouse.move(0, 0)
const sessionId = await settled
if (MODE === 'record') {
+1 -1
View File
@@ -314,7 +314,7 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
const configureLater = page.getByRole('button', { name: 'Configure later' })
await configureLater.waitFor({ timeout: 30_000 })
await configureLater.click()
await page.locator('[data-composer-input][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
await page.locator('[data-composer-input][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
.waitFor({ timeout: 30_000 })
const exercised = await page.evaluate(async () => {
@@ -26,7 +26,7 @@
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- status: Deep diving...
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
+4 -2
View File
@@ -42,7 +42,9 @@ describe('web e2e: startup auto-selection', () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-first-workspace-stable-tree'))
await page.locator(`${ROOT_PHASE}[data-phase="hero"]`).waitFor({ timeout: 15_000 })
const headline = page.getByText('Into the Unknown', { exact: true })
const fishHitbox = headline.locator('xpath=preceding-sibling::span[1]')
// The headline text sits in its own span inside the title group; the fish
// hitbox precedes the group, not the text span.
const fishHitbox = headline.locator('xpath=../preceding-sibling::span[1]')
const fish = fishHitbox.locator('svg')
expect(await fish.evaluate(node => getComputedStyle(node).color))
.toBe(await headline.evaluate(node => getComputedStyle(node).color))
@@ -130,7 +132,7 @@ describe('web e2e: startup auto-selection', () => {
expect(await page.locator('[data-composer-input]').first().isVisible()).toBe(true)
releaseOpening()
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
.waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
+1 -1
View File
@@ -23,7 +23,7 @@ const SEED_ID = 'stats-paged-history-web-e2e'
/** Turn count: 2 surface messages per turn, so 28 turns overflow one 50-message page. */
const TURNS = 28
const FULL_COUNTS = `${TURNS} turns · ${TURNS} steps`
const FULL_COUNTS = `${TURNS} turns ${TURNS} steps`
/**
* Generate the seed: TURNS closed single-step turns of one short user prompt
+3 -3
View File
@@ -423,7 +423,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(await page.locator('[data-composer-seat]').evaluate(element =>
getComputedStyle(element).visibility)).toBe('hidden')
releaseCatalog()
const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
const input = page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' })
await input.waitFor({ timeout: 15_000 })
await expect.poll(() => input.isEnabled(), { timeout: 15_000 }).toBe(true)
acknowledgeReloadConnectionLoss(tripwire, warningStart)
@@ -447,7 +447,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
resolveEnded()
})
})
const input = page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' })
const input = page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' })
await input.fill(FOLLOWUP)
await input.press('Enter')
await expect.poll(
@@ -537,7 +537,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
.click()
await page.getByRole('button', { name: '3 subagents' }).hover()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
await page.getByRole('textbox', { name: 'Message or run a task... / commands, @ files or sessions' }).waitFor()
await page.getByRole('textbox', { name: 'Message or run a task, / commands, @ files or sessions' }).waitFor()
const forkResponse = page.waitForResponse(response =>
new URL(response.url()).pathname === '/api/session/fork')
await page.getByRole('button', { name: 'Branch into a new conversation' }).last().click()
+2 -2
View File
@@ -113,7 +113,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
await dialog.getByRole('button', { name: 'Open', exact: true }).click()
// The pick connected the workspace: the blank session's live composer
// replaces the locked placeholder and enables.
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build... / commands, @ files or sessions"]')
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="Describe what you want to build, / commands, @ files or sessions"]')
.waitFor({ timeout: 15_000 })
}
@@ -136,7 +136,7 @@ export async function connectFreshWorkspaceZh(page: Page, root: string, name = '
await pathInput.fill(join(root, name))
await pathInput.press('Enter')
await dialog.getByRole('button', { name: '打开', exact: true }).click()
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容… / 调用指令 @ 文件或对话"]')
await page.locator('[data-composer-input][contenteditable="true"][data-placeholder="描述你想要构建的内容, / 调用指令, @ 文件或对话"]')
.waitFor({ timeout: 15_000 })
}
@@ -1,10 +1,25 @@
/* Agent-preset chip on the new-session screen, beside the workspace picker.
Geometry mirrors HeroShell's .workspace so the two read as one row. */
/* Overflow-driven degradation (mirrors HeroShell's .workspace), no
breakpoint: the label shrinks with an ellipsis down to nothing, so the
preset icon and chevron always sit snug against whatever text remains —
full text, ellipsized text, or none. */
/* The Menu anchor wrapper between the row and this chip: its flex
auto-minimum would hold the full label width and the row could never
compress the chip. The explicit floor is the icon-only end state — 8px
paddings + 16px icon + two 4px gaps + 14px chevron — so the glyphs never
crush once the label has ellipsized away to nothing. */
.menuAnchor {
min-width: 54px;
max-width: 100%;
}
.seat {
display: inline-flex;
align-items: center;
gap: 4px;
min-width: 0;
max-width: min(100%, 240px);
min-height: 28px;
padding: 0 8px;
@@ -16,8 +31,6 @@
line-height: 20px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
@@ -36,6 +49,13 @@
color: var(--dsw-alias-label-primary);
}
.seatLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
then the name's characters fade up on a stagger (delays set inline per
@@ -181,6 +181,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
}}
align="start"
portal
className={css.menuAnchor}
anchor={(
<button
type="button"
@@ -192,7 +193,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat,
onClick={() => { setOpen(value => !value) }}
>
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
{shownLabel}
<span className={css.seatLabel}>{shownLabel}</span>
<IconChevronDownOutline14 className={css.chevron} />
</button>
)}
@@ -106,6 +106,18 @@
opacity: 1;
}
@media (pointer: coarse) {
.remove {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.remove {
transition: none;
}
}
.card:hover .name,
.card:focus-within .name {
padding-right: 18px;
@@ -1,4 +1,4 @@
import { DocumentFileIcon, fileSizeText } from '@deepseek-ai/dsh-client-ui-primitives'
import { DocumentFileIcon, fileSizeText, IconCloseFill14 } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './FileCard.module.css'
/** Localized strings consumed by one pending-file card. */
@@ -72,9 +72,7 @@ export function FileCard({
aria-label={labels.remove}
onClick={onRemove}
>
<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden>
<path d="M3 3L13 13M13 3L3 13" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
<IconCloseFill14 size={12} />
</button>
{state === 'uploading' && (
<span className={css.progressTrack} aria-hidden>
@@ -1,6 +1,9 @@
.rail {
min-width: 0;
padding: 4px 12px 0;
padding: 2px 10px 0;
/* The card's flex gap (12px) separates every row pair; the thumbnails sit
closer to the draft text than the other rows, so pull back 6 here. */
margin-bottom: -6px;
}
.imageItem {
+2 -2
View File
@@ -26,7 +26,7 @@ import { EMPTY_CHAT_SNAPSHOT } from './contract/snapshot.ts'
import { ApprovalCommand } from './chat/ApprovalCommand.tsx'
import { ChatView } from './chat/ChatView.tsx'
import { registerChatNodeRenderers } from './chat/register-node-renderers.ts'
import { StatsLine } from './chat/StatsLine.tsx'
import { StatsPills } from './chat/StatsPills.tsx'
import { registerConversationNodes } from './conversation-nodes/register.ts'
import { en, NS, zh } from './locale.ts'
import { TranscriptViewRow, type TranscriptViewRowInjected } from './settings/TranscriptViewRow.tsx'
@@ -165,7 +165,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,
}, StatsLine))
}, StatsPills))
ctx.slots.inject('conversation.approval.detail', () =>
ctx.slots.register({ name: 'conversation.approval.detail' }, ApprovalCommand))
@@ -1,26 +0,0 @@
/* Session stats row: 13/20 tertiary text (the secondary content tier) under
the flow, aligned to the shared message column axis
(--dsh-chat-content-width). */
.root {
/* Block, not flex: text-overflow only elides a block's inline content, so
an overlong line ends in … instead of a mid-glyph clip. */
display: block;
text-align: center;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sep {
color: var(--dsw-alias-separator-primary);
margin: 0 10px; /* carries the former flex gap */
}
@@ -1,209 +0,0 @@
// Settled-node identity prevents stream-delta updates from rerendering this row.
// Mounted on 'conversation.composer.dock' so it sticks with the composer in the
// active conversation scrollport (see ConversationRoot data-conversation-scroll).
import { Fragment, memo, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { Tooltip } 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'
// 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 { ChatSnapshot } from '../contract/snapshot.ts'
import { formatTokensPerSecond } from './message-chrome.ts'
import { assistantStepReading } from '../contract/turn-metrics.ts'
import { formatCacheHitPercent, formatTokens } from './token-format.ts'
import css from './StatsLine.module.css'
interface WindowStats {
turns: number
steps: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
ttftMs: number
/** Steps carrying a recorded TTFT. */
ttftSteps: number
/** Summed decode wall time over steps that also report output tokens. */
decodeMs: number
/** Summed output tokens over the same decode-timed steps. */
decodeTokens: number
}
/**
* Fold assistant and tool-result nodes into window-scoped display totals —
* the FALLBACK for assemblies without the `sessionStats` projection.
*
* Every displayed figure rides that durable whole-log projection (and token
* accounting rides `tokenUsage`) because the window is paged and compaction
* rewrites it; this fold answers "what is on screen" only when no projection
* value is served. Its field names deliberately mirror the projection's so
* the two swap wholesale.
* @param nodes - snapshot nodes.
* @returns fallback counts and summed wall times.
*/
export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
const turns = new Set<number>()
let steps = 0
let llmMs = 0
let toolMs = 0
let ttftMs = 0
let ttftSteps = 0
let decodeMs = 0
let decodeTokens = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
continue
}
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const reading = assistantStepReading(node)
if (reading.ttftMs !== null) {
ttftMs += reading.ttftMs
ttftSteps += 1
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
decodeMs += reading.decodeMs
decodeTokens += reading.outputTokens
}
}
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
}
/**
* Compact duration: 45.2s under a minute, 2m42s from there on.
* @param ms - duration in milliseconds.
* @returns display string.
*/
export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
const s = ms / 1_000
if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 })
const whole = Math.round(s)
return t('duration.compactMinutes', {
minutes: Math.floor(whole / 60),
seconds: whole % 60,
})
}
/**
* Display-ready cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns integer text when integer rounding stays below 100, otherwise the
* minimum decimal precision that still rounds below 100; a full hit returns
* 100, and no billed input returns null.
*/
export function cacheHitPercent(usage: TokenUsageProjection): string | null {
const denominator = billedInputTokens(usage)
return formatCacheHitPercent(usage.cacheReadTokens, denominator)
}
/**
* Sum the three disjoint prompt-side billing buckets.
* @param usage - the session's token-usage projection value.
* @returns billed input tokens.
*/
export function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsLineProps {
useChat: SnapshotSelectorHook<ChatSnapshot>
useProjection: UseProjection
/** The owning dock's locale seat. */
t: ChatViewSlotProps['t']
}
/** Render and measure one non-empty statistics line. */
const StatsLineContent = memo(function StatsLineContent({
groups,
line,
}: {
readonly groups: readonly string[]
readonly line: string
}) {
const rootRef = useRef<HTMLDivElement | null>(null)
const [truncated, setTruncated] = useState(false)
const measure = useCallback(() => {
const el = rootRef.current
if (el === null) return
const next = el.scrollWidth > el.clientWidth
setTruncated(current => current === next ? current : next)
}, [])
useLayoutEffect(() => {
const el = rootRef.current
if (el === null || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(measure)
observer.observe(el)
return () => { observer.disconnect() }
}, [measure])
useLayoutEffect(measure, [line, measure])
return (
<Tooltip label={line} side="top" delayMs={500} disabled={!truncated}>
<div ref={rootRef} className={css.root}>
{groups.map((group, i) => (
<Fragment key={group}>
{i > 0 && <><span className={css.sep} aria-hidden>|</span>{' '}</>}
<span>{group}</span>
</Fragment>
))}
</div>
</Tooltip>
)
})
export const StatsLine = memo(function StatsLine({ useChat, useProjection, t }: StatsLineProps) {
const settledNodes = useChat(s => s.legacy.nodes)
const usage = useProjection('tokenUsage')
// Every figure rides the durable sessionStats projection, so paging and
// compaction cannot change any of them; an assembly without the unit falls
// back to the window-scoped fold wholesale (same field names), paid only
// while no projection value is served.
const projected = useProjection('sessionStats')
const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
// Pipe-separated groups (figma stats strip); a group with no data drops out whole.
const groups: string[] = []
if (stats.steps > 0) {
groups.push(t('stats.counts', { turns: stats.turns, steps: stats.steps }))
const durations: string[] = []
if (stats.llmMs > 0) durations.push(t('stats.llm', { duration: formatDuration(stats.llmMs, t) }))
if (stats.toolMs > 0) durations.push(t('stats.toolCall', { duration: formatDuration(stats.toolMs, t) }))
if (durations.length > 0) groups.push(durations.join(' · '))
const speeds: string[] = []
if (stats.ttftSteps > 0) {
speeds.push(t('stats.ttftAverage', { duration: formatDuration(stats.ttftMs / stats.ttftSteps, t) }))
}
if (stats.decodeMs > 0) {
speeds.push(t('stats.tokensPerSecond', {
throughput: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
}))
}
if (speeds.length > 0) groups.push(speeds.join(' · '))
}
// Context occupancy deliberately lives on the composer's ContextMeter ring,
// not here — one home per fact.
// Billing rides the durable projection, so these survive paging and
// compaction. Gated on actual token activity: a session whose steps all
// settled without billing (e.g. every request failed) shows its counts
// without a zero-token group.
if (usage !== undefined
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)) {
const cacheHit = cacheHitPercent(usage)
if (cacheHit !== null) groups.push(t('stats.cacheHit', { percent: cacheHit }))
groups.push(t('stats.tokens', {
input: formatTokens(billedInputTokens(usage), t),
output: formatTokens(usage.outputTokens, t),
}))
}
const line = groups.join(' | ')
if (groups.length === 0) return null
return <StatsLineContent groups={groups} line={line} />
})
@@ -0,0 +1,68 @@
/* Two icon pills side by side under the composer, 13/20 tertiary text tier
with a hover-pill affordance; each pill's click-open panel wears the shared
stat-dialog.module.css skin. */
.root {
display: flex;
justify-content: center;
gap: 12px;
max-width: var(--dsh-chat-content-width);
width: 100%;
margin: 0 auto;
box-sizing: border-box;
padding: 4px calc(var(--dsh-composer-side-clearance) + 16px) 0px;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
}
/* Dialog anchor: shrink-wraps its pill so the panel clamp measures the pill. */
.anchor {
display: inline-flex;
min-width: 0;
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
max-width: 100%;
padding: 1px 8px;
border: none;
border-radius: 24px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-variant-numeric: tabular-nums;
line-height: inherit;
white-space: nowrap;
}
.pill svg {
width: 14px;
height: 14px;
flex: none;
}
/* Only the button form (a pill with dialog rows to open) invites interaction;
the static span form keeps the resting tertiary reading. */
button.pill {
cursor: pointer;
}
button.pill:hover,
button.pill[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
.label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.sep {
color: var(--dsw-alias-separator-primary);
margin: 0 6px;
}
@@ -0,0 +1,353 @@
// 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.
// 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).
import { memo, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { IconDatabaseOutline16, IconGaugeOutline16 } 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'
// 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 { ChatSnapshot } from '../contract/snapshot.ts'
import { formatTokensPerSecond } from './message-chrome.ts'
import { assistantStepReading } from '../contract/turn-metrics.ts'
import { formatCacheHitPercent, formatExactTokens, formatTokens } from './token-format.ts'
import { MEASURE_STYLE, useStatDialog } from './stat-dialog.ts'
import css from './StatsPills.module.css'
import dialogCss from './stat-dialog.module.css'
interface WindowStats {
turns: number
steps: number
/** Summed request wall time (step/start → assistant/message); 0 when no node carries timing. */
llmMs: number
/** Summed tool wall time (tool/call → tool/result); 0 when no pair is in-window. */
toolMs: number
/** Summed first-token latency over `ttftSteps`; 0 when no step records it. */
ttftMs: number
/** Steps carrying a recorded TTFT. */
ttftSteps: number
/** Summed decode wall time over steps that also report output tokens. */
decodeMs: number
/** Summed output tokens over the same decode-timed steps. */
decodeTokens: number
}
/**
* Fold assistant and tool-result nodes into window-scoped display totals —
* the FALLBACK for assemblies without the `sessionStats` projection.
*
* Every displayed figure rides that durable whole-log projection (and token
* accounting rides `tokenUsage`) because the window is paged and compaction
* rewrites it; this fold answers "what is on screen" only when no projection
* value is served. Its field names deliberately mirror the projection's so
* the two swap wholesale.
* @param nodes - snapshot nodes.
* @returns fallback counts and summed wall times.
*/
export function deriveStats(nodes: ChatSnapshot['legacy']['nodes']): WindowStats {
const turns = new Set<number>()
let steps = 0
let llmMs = 0
let toolMs = 0
let ttftMs = 0
let ttftSteps = 0
let decodeMs = 0
let decodeTokens = 0
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.callTime !== null) toolMs += Math.max(0, node.time - node.callTime)
continue
}
if (node.kind !== 'assistant') continue
turns.add(node.turn)
steps += 1
if (node.timing !== undefined && node.timing.stepStartTime !== null) {
llmMs += Math.max(0, node.timing.completedTime - node.timing.stepStartTime)
}
const reading = assistantStepReading(node)
if (reading.ttftMs !== null) {
ttftMs += reading.ttftMs
ttftSteps += 1
}
if (reading.decodeMs !== null && reading.outputTokens !== null) {
decodeMs += reading.decodeMs
decodeTokens += reading.outputTokens
}
}
return { turns: turns.size, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }
}
/**
* Compact duration: 45.2s under a minute, 2m42s from there on.
* @param ms - duration in milliseconds.
* @returns display string.
*/
export function formatDuration(ms: number, t: ChatViewSlotProps['t']): string {
const s = ms / 1_000
if (s < 60) return t('duration.compactSeconds', { seconds: Math.round(s * 10) / 10 })
const whole = Math.round(s)
return t('duration.compactMinutes', {
minutes: Math.floor(whole / 60),
seconds: whole % 60,
})
}
/**
* Display-ready cache-hit share of prompt-side input over the whole durable log.
* @param usage - the session's token-usage projection value.
* @returns integer text when integer rounding stays below 100, otherwise the
* minimum decimal precision that still rounds below 100; a full hit returns
* 100, and no billed input returns null.
*/
export function cacheHitPercent(usage: TokenUsageProjection): string | null {
const denominator = billedInputTokens(usage)
return formatCacheHitPercent(usage.cacheReadTokens, denominator)
}
/**
* Sum the three disjoint prompt-side billing buckets.
* @param usage - the session's token-usage projection value.
* @returns billed input tokens.
*/
export function billedInputTokens(usage: TokenUsageProjection): number {
return usage.uncachedInputTokens + usage.cacheReadTokens + usage.cacheWriteTokens
}
/** Props: the conversation-snapshot selector plus the projection read seat. */
export interface StatsPillsProps {
useChat: SnapshotSelectorHook<ChatSnapshot>
useProjection: UseProjection
/** The owning dock's locale seat. */
t: ChatViewSlotProps['t']
}
function exactCount(value: number, t: ChatViewSlotProps['t']): string {
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
}
/** External open state one pill's dialog reads and writes (the row's exclusive slot). */
type PillDialog = Pick<ReturnType<typeof useStatDialog>, 'open' | 'setOpen'>
function TimePill({ stats, t, dialog }: {
stats: WindowStats
t: ChatViewSlotProps['t']
dialog: PillDialog
}) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
const tps = stats.decodeMs > 0
? t('message.tokensPerSecond', {
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
})
: null
const label = (
<span className={css.label}>
{counts}
{tps !== null && (
<>
<span className={css.sep} aria-hidden>·</span>
{tps}
</>
)}
</span>
)
// A window without one timed figure has no dialog rows to show, so the pill
// stays a plain reading instead of a button opening an empty dialog.
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) {
return (
<span className={css.anchor}>
<span className={css.pill}>
<IconGaugeOutline16 />
{label}
</span>
</span>
)
}
return (
<span ref={rootRef} className={css.anchor}>
<button
type="button"
className={css.pill}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={tps === null ? counts : `${counts} · ${tps}`}
onClick={() => { setOpen(!open) }}
>
<IconGaugeOutline16 />
{label}
</button>
{open && createPortal(
<div
ref={panelRef}
className={dialogCss.panel}
role="dialog"
aria-label={t('stats.dialog.title')}
style={pos ?? MEASURE_STYLE}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconGaugeOutline16 />
{t('stats.dialog.title')}
</span>
</div>
<div className={dialogCss.titleRule} aria-hidden />
<dl className={dialogCss.details} data-session-stats-details>
{stats.llmMs > 0 && (
<>
<dt>{t('stats.dialog.llmTime')}</dt>
<dd>{formatDuration(stats.llmMs, t)}</dd>
</>
)}
{stats.toolMs > 0 && (
<>
<dt>{t('stats.dialog.toolTime')}</dt>
<dd>{formatDuration(stats.toolMs, t)}</dd>
</>
)}
{stats.ttftSteps > 0 && (
<>
<dt>{t('stats.dialog.ttft')}</dt>
<dd>{formatDuration(stats.ttftMs / stats.ttftSteps, t)}</dd>
</>
)}
{stats.decodeMs > 0 && (
<>
<dt>{t('stats.dialog.speed')}</dt>
<dd>{t('message.tokensPerSecond', {
tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)),
})}</dd>
</>
)}
</dl>
</div>,
document.body,
)}
</span>
)
}
function UsagePill({ usage, t, dialog }: {
usage: TokenUsageProjection
t: ChatViewSlotProps['t']
dialog: PillDialog
}) {
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog(dialog)
// Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
const total = billedInputTokens(usage) + usage.outputTokens
const totalText = t('message.turnUsage.count', { count: formatTokens(total, t) })
const cacheHit = cacheHitPercent(usage)
const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
return (
<span ref={rootRef} className={css.anchor}>
<button
type="button"
className={css.pill}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
onClick={() => { setOpen(!open) }}
>
<IconDatabaseOutline16 />
<span className={css.label}>
{totalText}
{cacheHitText !== null && (
<>
<span className={css.sep} aria-hidden>·</span>
{cacheHitText}
</>
)}
</span>
</button>
{open && createPortal(
<div
ref={panelRef}
className={dialogCss.panel}
role="dialog"
aria-label={t('stats.dialog.usageTitle')}
style={pos ?? MEASURE_STYLE}
>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconDatabaseOutline16 />
{t('stats.dialog.usageTitle')}
</span>
<span className={dialogCss.titleValue}>{exactCount(total, t)}</span>
</div>
<div className={dialogCss.titleRule} aria-hidden />
{/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror
TurnUsagePanel's per-turn dl: same skin, different data contract (all
buckets always present here; per-turn fields are optional). */}
<dl className={dialogCss.details} data-session-stats-usage>
{cacheHit !== null && (
<>
<dt>{t('message.turnUsage.cacheHit')}</dt>
<dd>{`${cacheHit}%`}</dd>
</>
)}
<dt>{t('message.turnUsage.input')}</dt>
<dd>{exactCount(usage.uncachedInputTokens, t)}</dd>
<dt>{t('message.turnUsage.cacheRead')}</dt>
<dd>{exactCount(usage.cacheReadTokens, t)}</dd>
<dt>{t('message.turnUsage.cacheWrite')}</dt>
<dd>{exactCount(usage.cacheWriteTokens, t)}</dd>
<dt>{t('message.turnUsage.output')}</dt>
<dd>{exactCount(usage.outputTokens, t)}</dd>
</dl>
{/* jscpd:ignore-end */}
</div>,
document.body,
)}
</span>
)
}
export const StatsPills = memo(function StatsPills({ useChat, useProjection, t }: StatsPillsProps) {
const settledNodes = useChat(s => s.legacy.nodes)
const usage = useProjection('tokenUsage')
// One exclusive slot for both dialogs: opening either pill closes the other.
const [openPill, setOpenPill] = useState<'time' | 'usage' | null>(null)
// Every figure rides the durable sessionStats projection, so paging and
// compaction cannot change any of them; an assembly without the unit falls
// back to the window-scoped fold wholesale (same field names), paid only
// while no projection value is served.
const projected = useProjection('sessionStats')
const stats = useMemo(() => projected ?? deriveStats(settledNodes), [projected, settledNodes])
// Gated on actual token activity: a session whose steps all settled without
// billing (e.g. every request failed) shows its counts without a usage pill.
const hasTokens = usage !== undefined
&& (billedInputTokens(usage) > 0 || usage.outputTokens > 0)
if (stats.steps === 0 && !hasTokens) return null
// data-composer-stats: InputBar's `.root:has([data-composer-stats])` rule
// tightens the composer's bottom clearance only while this row renders.
return (
<div className={css.root} data-composer-stats>
{stats.steps > 0 && (
<TimePill
stats={stats}
t={t}
dialog={{
open: openPill === 'time',
setOpen: (open) => { setOpenPill(open ? 'time' : null) },
}}
/>
)}
{hasTokens && (
<UsagePill
usage={usage}
t={t}
dialog={{
open: openPill === 'usage',
setOpen: (open) => { setOpenPill(open ? 'usage' : null) },
}}
/>
)}
</div>
)
})
@@ -1,6 +1,5 @@
/* Icon-row Turn-usage pill (data icon + turn total) plus its click-open
Turn-details dialog (menu surface: r12, elevation-prominent —
ContextMeter's panel skin). */
/* Icon-row Turn-usage pill (data icon + turn total); its click-open dialog
wears the shared stat-dialog.module.css skin. */
.root {
display: inline-flex;
@@ -74,93 +73,3 @@
margin-left: 0;
}
}
/* Portal surface: fixed in the viewport, left/top supplied inline from the
anchored-position clamp so the panel keeps its 12px viewport margin instead
of hanging off the trigger and clipping at the window edge. Portaled panels
layer above modal overlays (z 1000). */
.panel {
position: fixed;
z-index: 1100;
box-sizing: border-box;
/* Size to the widest row so provider/model stays on one line, within a cap.
Both bounds yield to a viewport narrower than themselves (12px margins),
so a fixed floor cannot push the panel past the placement clamp. */
width: max-content;
min-width: min(300px, calc(100vw - 24px));
max-width: min(440px, calc(100vw - 24px));
padding: 16px;
border: 0;
border-radius: 12px;
background: var(--dsw-specific-menu);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
/* Dialog heading row: the section name left, the section's headline value
(the usage total) right, both at the primary weight. */
.title {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
color: var(--dsw-alias-label-primary);
font-weight: 500;
}
/* Rule under each section heading, above its rows. */
.titleRule {
margin-bottom: 10px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.titleValue {
font-variant-numeric: tabular-nums;
}
/* Section-heading glyph seated left of the section name. */
.titleLabel {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.titleLabel svg {
width: 14px;
height: 14px;
flex: none;
}
.details {
display: grid;
grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
gap: 6px 16px;
margin: 0;
color: var(--dsw-alias-label-tertiary);
}
.details dt,
.details dd {
min-width: 0;
margin: 0;
}
.details dd {
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
text-align: right;
}
.details .route {
overflow-wrap: anywhere;
}
.reasoning {
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
@@ -3,16 +3,15 @@
// 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.
import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
import { createPortal } from 'react-dom'
import {
IconClockOutline16, IconDatabaseOutline16, useAnchoredPosition, useDismissOnOutsidePointer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { IconClockOutline16, IconDatabaseOutline16 } 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'
import dialogCss from './stat-dialog.module.css'
export interface TurnUsagePanelProps {
usage: TurnTokenUsage
@@ -39,58 +38,6 @@ function formatExactCount(value: number, t: ChatViewSlotProps['t']): string {
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
}
/** Viewport margin the placement clamp keeps (the Menu portal margin). */
const PANEL_MARGIN = 12
/** Distance between the trigger's top edge and the panel's bottom. */
const PANEL_GAP = 8
/**
* Unplaced portal panel: hidden but laid out so the clamp measures real
* dimensions (the `useAnchoredPosition` measure pass).
*/
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
interface StatDialogSeat {
open: boolean
setOpen: (open: boolean) => void
rootRef: MutableRefObject<HTMLSpanElement | null>
panelRef: MutableRefObject<HTMLDivElement | null>
pos: CSSProperties | null
}
/** One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close. */
function useStatDialog(): StatDialogSeat {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
// Portal placement: the dialog is fixed above the trigger and clamped inside
// the viewport, so a trigger near the window edge cannot push it off-screen.
const pos = useAnchoredPosition({
open,
anchorRef: rootRef,
panelRef,
side: 'top',
gap: PANEL_GAP,
margin: PANEL_MARGIN,
})
// Outside pointerdown closes through the shared primitive; the portaled
// panel counts as inside. Escape close stays local, one listener while open.
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [open])
return { open, setOpen, rootRef, panelRef, pos }
}
/**
* Turn-usage IconActions pill with a click-open Turn-usage details dialog.
* @param props - Turn usage buckets and locale seat.
@@ -120,24 +67,24 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
{open && createPortal(
<div
ref={panelRef}
className={css.panel}
className={dialogCss.panel}
role="dialog"
aria-label={t('message.turnUsage.title')}
style={pos ?? MEASURE_STYLE}
>
<div className={css.title}>
<span className={css.titleLabel}>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconDatabaseOutline16 />
{t('message.turnUsage.title')}
</span>
<span className={css.titleValue}>{formatExactCount(usage.totalTokens, t)}</span>
<span className={dialogCss.titleValue}>{formatExactCount(usage.totalTokens, t)}</span>
</div>
<div className={css.titleRule} aria-hidden />
<dl className={css.details} data-turn-usage-details>
<div className={dialogCss.titleRule} aria-hidden />
<dl className={dialogCss.details} data-turn-usage-details>
{routes !== '' && (
<>
<dt>{t('message.turnUsage.model')}</dt>
<dd className={css.route}>{routes}</dd>
<dd className={dialogCss.route}>{routes}</dd>
</>
)}
{cacheHit !== null && (
@@ -164,7 +111,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
<dd>
{formatExactCount(usage.outputTokens, t)}
{usage.reasoningTokens !== undefined && (
<span className={css.reasoning}>
<span className={dialogCss.reasoning}>
{t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })}
</span>
)}
@@ -199,19 +146,19 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan
{open && createPortal(
<div
ref={panelRef}
className={css.panel}
className={dialogCss.panel}
role="dialog"
aria-label={t('message.turnTime.title')}
style={pos ?? MEASURE_STYLE}
>
<div className={css.title}>
<span className={css.titleLabel}>
<div className={dialogCss.title}>
<span className={dialogCss.titleLabel}>
<IconClockOutline16 />
{t('message.turnTime.title')}
</span>
</div>
<div className={css.titleRule} aria-hidden />
<dl className={css.details} data-turn-time-details>
<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 && (
@@ -0,0 +1,94 @@
/* Stat-dialog surface skin shared by the Turn-stat pills (TurnUsagePanel) and
the session stats pills (StatsPills): menu surface, r12, elevation-prominent
— ContextMeter's panel skin. Placement comes from useStatDialog
(stat-dialog.ts). */
/* Portal surface: fixed in the viewport, left/top supplied inline from the
anchored-position clamp so the panel keeps its 12px viewport margin instead
of hanging off the trigger and clipping at the window edge. Portaled panels
layer above modal overlays (z 1000). */
.panel {
position: fixed;
z-index: 1100;
box-sizing: border-box;
/* Size to the widest row so provider/model stays on one line, within a cap.
Both bounds yield to a viewport narrower than themselves (12px margins),
so a fixed floor cannot push the panel past the placement clamp. */
width: max-content;
min-width: min(300px, calc(100vw - 24px));
max-width: min(440px, calc(100vw - 24px));
padding: 16px;
border: 0;
border-radius: 12px;
background: var(--dsw-specific-menu);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-label-secondary);
cursor: default;
}
/* Dialog heading row: the section name left, the section's headline value
right, both at the primary weight. */
.title {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 8px;
color: var(--dsw-alias-label-primary);
font-weight: 500;
}
/* Rule under each section heading, above its rows. */
.titleRule {
margin-bottom: 10px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.titleValue {
font-variant-numeric: tabular-nums;
}
/* Section-heading glyph seated left of the section name. */
.titleLabel {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.titleLabel svg {
width: 14px;
height: 14px;
flex: none;
}
.details {
display: grid;
grid-template-columns: minmax(76px, auto) minmax(0, 1fr);
gap: 6px 16px;
margin: 0;
color: var(--dsw-alias-label-tertiary);
}
.details dt,
.details dd {
min-width: 0;
margin: 0;
}
.details dd {
color: var(--dsw-alias-label-secondary);
font-variant-numeric: tabular-nums;
text-align: right;
}
.details .route {
overflow-wrap: anywhere;
}
.reasoning {
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
}
@@ -0,0 +1,66 @@
// One trigger-anchored stat dialog seat shared by the Turn-stat pills
// (TurnUsagePanel) and the composer-dock session stats pills (StatsPills);
// the matching dialog surface skin lives in stat-dialog.module.css.
import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from 'react'
import { useAnchoredPosition, useDismissOnOutsidePointer } from '@deepseek-ai/dsh-client-ui-primitives'
/** Viewport margin the placement clamp keeps (the Menu portal margin). */
const PANEL_MARGIN = 12
/** Distance between the trigger's top edge and the panel's bottom. */
const PANEL_GAP = 8
/**
* Unplaced portal panel: hidden but laid out so the clamp measures real
* dimensions (the `useAnchoredPosition` measure pass).
*/
export const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/** Open state, refs, and clamped placement for one stat dialog. */
export interface StatDialogSeat {
open: boolean
setOpen: (open: boolean) => void
rootRef: MutableRefObject<HTMLSpanElement | null>
panelRef: MutableRefObject<HTMLDivElement | null>
pos: CSSProperties | null
}
/**
* One trigger-anchored dialog seat: open state, viewport-clamped placement, outside-close.
* @param controlled - external open state; when given the seat reads and writes
* it instead of owning its own, letting sibling dialogs share one exclusive slot.
* @returns the seat; spread `pos ?? MEASURE_STYLE` onto the portaled panel.
*/
export function useStatDialog(controlled?: Pick<StatDialogSeat, 'open' | 'setOpen'>): StatDialogSeat {
const [ownOpen, setOwnOpen] = useState(false)
const open = controlled?.open ?? ownOpen
const setOpen = controlled?.setOpen ?? setOwnOpen
const rootRef = useRef<HTMLSpanElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
// Portal placement: the dialog is fixed above the trigger and clamped inside
// the viewport, so a trigger near the window edge cannot push it off-screen.
const pos = useAnchoredPosition({
open,
anchorRef: rootRef,
panelRef,
side: 'top',
gap: PANEL_GAP,
margin: PANEL_MARGIN,
})
// Outside pointerdown closes through the shared primitive; the portaled
// panel counts as inside. Escape close stays local, one listener while open.
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef)
useEffect(() => {
if (!open) return
const onKeyDown = (e: KeyboardEvent): void => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKeyDown)
return () => { document.removeEventListener('keydown', onKeyDown) }
}, [open, setOpen])
return { open, setOpen, rootRef, panelRef, pos }
}
@@ -79,7 +79,7 @@ export interface ChatTurnProcessPresentation {
readonly compactAnswer: boolean
}
/** Compatibility projection backing StatsLine and the legacy top-level snapshot fields. */
/** Compatibility projection backing StatsPills and the legacy top-level snapshot fields. */
export interface LegacyConversationSlice {
readonly nodes: readonly ConversationNode[]
readonly turnTimings: ReadonlyMap<number, { readonly startTime: number; readonly endTime?: number }>
@@ -1,4 +1,4 @@
// Latency/throughput folds shared by the settled turn footer and StatsLine.
// Latency/throughput folds shared by the settled turn footer and StatsPills.
import type {
AssistantMessageNode, ConversationNode,
@@ -730,7 +730,7 @@ const EMPTY_CONTRIBUTION: LegacyContribution = {
function legacyContribution(raw: ChatConversationViewNode): LegacyContribution {
const node = raw as ChatNode
// Content-free settled Assistants remain in the finalized compatibility
// stream so StatsLine preserves its pre-assembly step counts; hidden running
// stream so StatsPills preserves its pre-assembly step counts; hidden running
// attempts have no final Node to contribute.
if (raw.visibility !== 'visible' && node.kind !== 'assistant-step') return EMPTY_CONTRIBUTION
switch (node.kind) {
@@ -802,7 +802,7 @@ function sameContribution(left: LegacyContribution | undefined, right: LegacyCon
&& sameReferences(left.nodes, right.nodes)
}
/** Incremental compatibility projection for StatsLine and legacy top-level snapshot fields. */
/** Incremental compatibility projection for StatsPills and legacy top-level snapshot fields. */
class LegacySliceBuilder {
private readonly contributions = new Map<string, LegacyContribution>()
private readonly finalizedContributions = new Map<string, LegacyContribution>()
+14 -12
View File
@@ -10,13 +10,14 @@ export const zh = {
'duration.compactSeconds': '{seconds}秒',
'duration.compactMinutes': '{minutes}分{seconds}秒',
'duration.milliseconds': '{milliseconds}毫秒',
'stats.counts': '{turns} 轮 · {steps} 步',
'stats.llm': 'LLM {duration}',
'stats.toolCall': '工具调用 {duration}',
'stats.ttftAverage': '首 token 平均 {duration}',
'stats.tokensPerSecond': '{throughput} tok/s',
'stats.counts': '{turns} 轮 {steps} 步',
'stats.cacheHit': '缓存命中 {percent}%',
'stats.tokens': '输入 {input} tok · 输出 {output} tok',
'stats.dialog.title': '会话统计',
'stats.dialog.usageTitle': 'Token 用量',
'stats.dialog.llmTime': '模型用时',
'stats.dialog.toolTime': '工具调用用时',
'stats.dialog.ttft': '首 token 平均(TTFT)',
'stats.dialog.speed': '输出速度(TPS)',
'chat.loadingHistory': '载入历史…',
'chat.loadError': '历史加载失败:{message}({code})',
'chat.loadOlder': '加载更早',
@@ -118,13 +119,14 @@ export const en = {
'duration.compactSeconds': '{seconds}s',
'duration.compactMinutes': '{minutes}m{seconds}s',
'duration.milliseconds': '{milliseconds}ms',
'stats.counts': '{turns} turns · {steps} steps',
'stats.llm': 'LLM {duration}',
'stats.toolCall': 'Tool call {duration}',
'stats.ttftAverage': 'TTFT avg {duration}',
'stats.tokensPerSecond': '{throughput} tok/s',
'stats.counts': '{turns} turns {steps} steps',
'stats.cacheHit': 'Cache hit {percent}%',
'stats.tokens': 'Input {input} tok · Output {output} tok',
'stats.dialog.title': 'Session statistics',
'stats.dialog.usageTitle': 'Token usage',
'stats.dialog.llmTime': 'LLM time',
'stats.dialog.toolTime': 'Tool time',
'stats.dialog.ttft': 'Avg time to first token (TTFT)',
'stats.dialog.speed': 'Tokens per second (TPS)',
'chat.loadingHistory': 'Loading history…',
'chat.loadError': 'Failed to load history: {message} ({code})',
'chat.loadOlder': 'Load earlier',
@@ -16,18 +16,10 @@ import {
UserMessageNodeView,
} from '../src/client/chat/MessageItem.tsx'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.useRealTimers()
@@ -1042,7 +1034,7 @@ describe('small branch tails', () => {
expect(view.getByText('one-liner')).toBeTruthy()
})
it('StatsLine omits the cache-hit segment when no input accounting exists at all', () => {
it('StatsPills omits the cache-hit segment when no input accounting exists at all', () => {
// Cache hit is null only when all three prompt buckets are zero (pure
// output accounting) — any billed input makes it a real 0%.
const nodes = [{
@@ -1051,7 +1043,7 @@ describe('small branch tails', () => {
const snap = chatSnapshotFixture({ nodes })
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
<StatsPills
t={t}
useChat={bindSnapshotSelector(source)}
useProjection={(key: string) => key === 'tokenUsage'
@@ -1059,7 +1051,15 @@ describe('small branch tails', () => {
: undefined}
/>,
)
expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
// The untimed counts pill renders static, so the usage pill is the only button.
const [usagePill] = [...view.getAllByRole('button')] as [HTMLElement]
expect(view.getByText('1 轮 1 步').closest('button')).toBeNull()
expect(usagePill.textContent).toBe('10 tok')
// Pure output accounting still reaches the usage pill's click-open dialog rows.
fireEvent.click(usagePill)
const dialog = view.getByRole('dialog')
expect(dialog.textContent).toContain('输出10 tok')
expect(dialog.textContent).not.toContain('缓存命中')
})
})
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render } from '@testing-library/react'
import type {
AssistantMessageNode, ChatSnapshot, LegacyConversationSlice, ToolResultNode,
@@ -8,27 +8,17 @@ import type {
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { StatsLine, deriveStats, formatDuration, type StatsLineProps } from '../src/client/chat/StatsLine.tsx'
import { StatsPills, deriveStats, formatDuration, type StatsPillsProps } from '../src/client/chat/StatsPills.tsx'
import { formatTokens } from '../src/client/chat/token-format.ts'
import { en, zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
const t: StatsLineProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsLineProps['t'] = makeTranslate(en, commonEn)
const t: StatsPillsProps['t'] = makeTranslate(zh, commonZh)
const tEn: StatsPillsProps['t'] = makeTranslate(en, commonEn)
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
vi.restoreAllMocks()
vi.useRealTimers()
})
const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessageNode => ({
@@ -135,7 +125,7 @@ describe('formatters', () => {
})
})
describe('StatsLine', () => {
describe('StatsPills', () => {
const USAGE = { uncachedInputTokens: 10, outputTokens: 5, cacheReadTokens: 90, cacheWriteTokens: 0 }
/** A whole-log sessionStats value: zeros plus overrides. */
@@ -147,14 +137,14 @@ describe('StatsLine', () => {
}
/** Stub the projection seat: a key-addressed table of whole values. */
function projections(values: Record<string, unknown>): StatsLineProps['useProjection'] {
function projections(values: Record<string, unknown>): StatsPillsProps['useProjection'] {
return (key: string) => values[key]
}
function props(
source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void },
values: Record<string, unknown> = { tokenUsage: USAGE },
): StatsLineProps {
): StatsPillsProps {
return { useChat: bindSnapshotSelector(source), useProjection: projections(values), t: tEn }
}
@@ -162,37 +152,34 @@ describe('StatsLine', () => {
return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 }
}
it('mounts one size observer only while a non-empty line exists', () => {
let observers = 0
class CountingResizeObserver {
constructor(_callback: ResizeObserverCallback) { observers++ }
observe(): void {}
disconnect(): void {}
}
vi.stubGlobal('ResizeObserver', CountingResizeObserver)
const { set, source } = makeSource()
render(<StatsLine {...props(source, {})} />)
expect(observers).toBe(0)
act(() => { set({ nodes: [assistant(1, 1)] }) })
expect(observers).toBe(1)
act(() => { set({ nodes: [assistant(1, 1), assistant(2, 1)] }) })
expect(observers).toBe(1)
/** A step whose timing yields 3.8s LLM, 0.8s TTFT, and 20 tok/s over 60 tokens. */
const timedStep = (): AssistantMessageNode => ({
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
})
it('renders the grouped stats row and hides a brand-new empty session', () => {
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(<StatsLine {...props(source)} />)
// No timing on the fixture: the duration group drops out whole. Tokens come
// from the projection, so paging the window cannot change them.
expect(view.container.textContent).toBe('1 turns · 1 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
const view = render(<StatsPills {...props(source)} />)
// InputBar's `.root:has([data-composer-stats])` bottom-clearance rule keys
// off this attribute: present exactly while the row renders.
expect(view.container.querySelector('[data-composer-stats]')).toBeTruthy()
// No timing on the fixture: the speed segment drops out and the dialog
// would have no rows, so the counts reading stays a static pill (no button).
expect(view.getByText('1 turns 1 steps').closest('button')).toBeNull()
// Cache hit comes from the projection, so paging the window cannot change
// it; the usage pill leads with the whole-log token total. Its accessible
// name separates the segments the visual sep glyph joins.
const usagePill = view.getAllByRole('button')
expect(usagePill.map(pill => pill.textContent)).toEqual(['105 tok·Cache hit 90%'])
expect(usagePill[0]!.getAttribute('aria-label')).toBe('105 tok · Cache hit 90%')
const empty = makeSource()
const emptyView = render(<StatsLine {...props(empty.source, {
const emptyView = render(<StatsPills {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
contextPressure: {},
})} />)
expect(emptyView.container.textContent).toBe('')
expect(emptyView.container.querySelector('[data-composer-stats]')).toBeNull()
})
it.each([
@@ -214,153 +201,225 @@ describe('StatsLine', () => {
{ actual: '100%', tokenUsageValue: tokenUsage(10_000, 0), expected: 'Cache hit 100%' },
])('formats an actual $actual cache-hit ratio as $expected', ({ tokenUsageValue, expected }) => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsageValue })} />)
expect(view.container.textContent).toContain(expected)
const view = render(<StatsPills {...props(source, { tokenUsage: tokenUsageValue })} />)
expect(view.getAllByRole('button')[0]!.textContent).toContain(expected)
})
it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => {
vi.useFakeTimers()
// jsdom lays nothing out; fake a row narrower than its content.
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800)
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400)
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} />)
expect(view.container.textContent).toContain('Cache hit 99.95%')
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(499) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.container.querySelector('[role="tooltip"]')?.textContent)
.toBe('1 turns · 1 steps | Cache hit 99.95% | Input 10K tok · Output 1 tok')
it('exposes output speed on the counts pill when decode timing exists', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const timePill = view.getAllByRole('button')[0]!
expect(timePill.textContent).toBe('1 turns 1 steps·20 tok/s')
expect(timePill.getAttribute('aria-label')).toBe('1 turns 1 steps · 20 tok/s')
})
it('suppresses the tooltip while the row fits without truncation', () => {
vi.useFakeTimers()
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source)} />)
fireEvent.mouseEnter(view.container.firstElementChild!)
act(() => { vi.advanceTimersByTime(500) })
expect(view.container.querySelector('[role="tooltip"]')).toBeNull()
it('click-opens the time-and-speed dialog carrying the time split and speeds', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const timePill = view.getAllByRole('button')[0]!
expect(timePill.getAttribute('aria-haspopup')).toBe('dialog')
expect(timePill.getAttribute('aria-expanded')).toBe('false')
expect(view.queryByRole('dialog')).toBeNull()
fireEvent.click(timePill)
expect(timePill.getAttribute('aria-expanded')).toBe('true')
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('Session statistics')
// Portaled out of the composer dock.
expect(dialog.parentElement).toBe(document.body)
expect(dialog.firstChild?.textContent).toBe('Session statistics')
const details = dialog.querySelector('[data-session-stats-details]') as HTMLElement
expect(details).toBeTruthy()
expect(details.textContent).toContain('LLM time3.8s')
// No tool call in this session: the row is absent, not zeroed.
expect(details.textContent).not.toContain('Tool time')
expect(details.textContent).toContain('Avg time to first token (TTFT)0.8s')
expect(details.textContent).toContain('Tokens per second (TPS)20 tok/s')
// Token accounting lives on the usage pill's own dialog, not here.
expect(dialog.textContent).not.toContain('Token usage')
})
it('renders window latency and throughput beside the wall-time group', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source)} />)
expect(view.container.textContent).toContain('LLM 3.8s| TTFT avg 0.8s · 20 tok/s')
it('click-opens the token-usage dialog carrying the headline total and exact buckets', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const usagePill = view.getAllByRole('button')[1]!
fireEvent.click(usagePill)
expect(usagePill.getAttribute('aria-expanded')).toBe('true')
const dialog = view.getByRole('dialog')
expect(dialog.getAttribute('aria-label')).toBe('Token usage')
expect(dialog.parentElement).toBe(document.body)
// Headline total: all prompt-side buckets (10 + 90 + 0) plus output (5).
expect(dialog.firstChild?.textContent).toBe('Token usage105 tok')
const tokens = dialog.querySelector('[data-session-stats-usage]') as HTMLElement
expect(tokens).toBeTruthy()
expect(tokens.textContent).toContain('Cache hit90%')
expect(tokens.textContent).toContain('Uncached input10 tok')
expect(tokens.textContent).toContain('Cached input90 tok')
expect(tokens.textContent).toContain('Cache write0 tok')
expect(tokens.textContent).toContain('Output5 tok')
// The time split lives on the counts pill's own dialog, not here.
expect(dialog.textContent).not.toContain('LLM time')
})
it('takes every stats label from the active locale', () => {
const timed: AssistantMessageNode = {
...assistant(1, 1, { outputTokens: 60 }),
timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 },
}
const { source } = makeSource({ nodes: [timed] })
const view = render(<StatsLine {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
expect(view.container.textContent)
.toBe('1 轮 · 1 步| LLM 3.8秒| 首 token 平均 0.8秒 · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok')
it('closes the dialog on Escape or outside pointerdown', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const timePill = view.getAllByRole('button')[0]!
fireEvent.click(timePill)
expect(view.queryByRole('dialog')).toBeTruthy()
fireEvent.keyDown(document, { key: 'Escape' })
expect(view.queryByRole('dialog')).toBeNull()
expect(timePill.getAttribute('aria-expanded')).toBe('false')
fireEvent.click(timePill)
// A pointerdown inside the panel keeps it open; one outside closes it.
fireEvent.pointerDown(view.getByRole('dialog'))
expect(view.queryByRole('dialog')).toBeTruthy()
fireEvent.pointerDown(document.body)
expect(view.queryByRole('dialog')).toBeNull()
})
it('renders without ResizeObserver support', () => {
vi.unstubAllGlobals()
const { source } = makeSource({ nodes: [assistant(1, 1)] })
expect(() => render(<StatsLine {...props(source)} />)).not.toThrow()
it('keeps at most one dialog open: a sibling pill click swaps, never stacks', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source)} />)
const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
fireEvent.click(timePill)
expect(view.getByRole('dialog').getAttribute('aria-label')).toBe('Session statistics')
fireEvent.click(usagePill)
const dialogs = view.getAllByRole('dialog')
expect(dialogs).toHaveLength(1)
expect(dialogs[0]!.getAttribute('aria-label')).toBe('Token usage')
expect(timePill.getAttribute('aria-expanded')).toBe('false')
expect(usagePill.getAttribute('aria-expanded')).toBe('true')
})
it('keeps durable token groups after the visible step window is empty', () => {
it('takes every pill and dialog label from the active locale', () => {
const { source } = makeSource({ nodes: [timedStep()] })
const view = render(<StatsPills {...props(source, { tokenUsage: tokenUsage(9_995, 5) })} t={t} />)
const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
expect(timePill.textContent).toBe('1 轮 1 步·20 tok/s')
// Whole-log total 9995 + 5 + 1 compacts to 10K.
expect(usagePill.textContent).toBe('10K tok·缓存命中 99.95%')
fireEvent.click(timePill)
const timeDialog = view.getByRole('dialog')
expect(timeDialog.getAttribute('aria-label')).toBe('会话统计')
expect(timeDialog.textContent).toContain('模型用时3.8秒')
expect(timeDialog.textContent).toContain('首 token 平均(TTFT)0.8秒')
expect(timeDialog.textContent).toContain('输出速度(TPS)20 tok/s')
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(usagePill)
const usageDialog = view.getByRole('dialog')
expect(usageDialog.getAttribute('aria-label')).toBe('Token 用量')
expect(usageDialog.textContent).toContain('未缓存输入5 tok')
})
it('keeps the durable usage pill after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: USAGE,
contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
})} />)
// Context occupancy lives on the composer's ContextMeter ring, not here.
expect(view.container.textContent)
.toBe('Cache hit 90%| Input 100 tok · Output 5 tok')
const pills = view.getAllByRole('button')
expect(pills).toHaveLength(1)
expect(pills[0]!.textContent).toBe('105 tok·Cache hit 90%')
})
it('drops every token group when no projection is composed', () => {
it('drops the usage pill when no projection is composed', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps')
const view = render(<StatsPills {...props(source, {})} />)
expect(view.container.textContent).toBe('1 turns 1 steps')
// The untimed window has no dialog rows either, so no button renders at all.
expect(view.queryAllByRole('button')).toHaveLength(0)
})
it('renders whole-session counts from the sessionStats projection over the paged window', () => {
// The bug's acceptance at unit level: one loaded page must not scope the
// counter — the durable projection's totals win over the window fold.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: USAGE,
sessionStats: sessionStats({ turns: 10, steps: 89 }),
})} />)
expect(view.container.textContent)
.toBe('10 turns · 89 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
expect(view.getByText('10 turns 89 steps')).toBeTruthy()
})
it('treats a defined zero-count projection as empty, not as fallback', () => {
// A composed unit always serves the key; all-zero genuinely means no
// closed step in the whole log, so nothing renders on a brand-new session.
const empty = makeSource()
const view = render(<StatsLine {...props(empty.source, {
const view = render(<StatsPills {...props(empty.source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
sessionStats: sessionStats({}),
})} />)
expect(view.container.textContent).toBe('')
})
it('hides the zero-token group when steps closed without any billed activity', () => {
it('hides the usage pill when steps closed without any billed activity', () => {
// A session whose only turn failed before billing (e.g. an auth error):
// the counts group renders alone, not an uninformative zero-token group.
// the counts pill renders alone, not an uninformative zero-token pill.
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 },
sessionStats: sessionStats({ turns: 1, steps: 1 }),
})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps')
expect(view.container.textContent).toBe('1 turns 1 steps')
expect(view.queryAllByRole('button')).toHaveLength(0)
})
it('keeps the counts group over an empty visible window when the projection carries totals', () => {
it('keeps the counts pill over an empty visible window when the projection carries totals', () => {
// Extends the durable-groups guarantee: full-session counts survive a
// window that compaction (or paging) left without assistant nodes.
const { source } = makeSource()
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: USAGE,
sessionStats: sessionStats({ turns: 7, steps: 44 }),
})} />)
expect(view.container.textContent)
.toBe('7 turns · 44 steps| Cache hit 90%| Input 100 tok · Output 5 tok')
expect(view.getByText('7 turns 44 steps')).toBeTruthy()
})
it('renders whole-log wall times and speeds from the projection, not the loaded window', () => {
// The 加载更早 hazard beyond counts: LLM/tool durations and the TTFT and
// throughput figures must not grow per loaded page either. An untimed
// 1-node window renders the projection's whole-log figures verbatim.
it('renders whole-log speed and dialog figures from the projection, not the loaded window', () => {
// The 加载更早 hazard beyond counts: the pill's speed segment and the
// dialog's time split, TTFT, and throughput must not grow per loaded page
// either. An untimed 1-node window renders the projection's whole-log figures.
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: USAGE,
sessionStats: sessionStats({
turns: 200, steps: 200, llmMs: 100_000, toolMs: 62_000,
ttftMs: 1_600, ttftSteps: 2, decodeMs: 3_000, decodeTokens: 60,
}),
})} />)
expect(view.container.textContent).toBe(
'200 turns · 200 steps| LLM 1m40s · Tool call 1m2s| TTFT avg 0.8s · 20 tok/s| Cache hit 90%| Input 100 tok · Output 5 tok',
)
const timePill = view.getAllByRole('button')[0]!
expect(timePill.textContent).toBe('200 turns 200 steps·20 tok/s')
fireEvent.click(timePill)
const dialog = view.getByRole('dialog')
expect(dialog.textContent).toContain('LLM time1m40s')
expect(dialog.textContent).toContain('Tool time1m2s')
expect(dialog.textContent).toContain('Avg time to first token (TTFT)0.8s')
expect(dialog.textContent).toContain('Tokens per second (TPS)20 tok/s')
})
it('omits cache hit when nothing was billed on the input side', () => {
it('omits the cache-hit segment when nothing was billed on the input side', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: { uncachedInputTokens: 0, outputTokens: 7, cacheReadTokens: 0, cacheWriteTokens: 0 },
})} />)
expect(view.container.textContent).toBe('1 turns · 1 steps| Input 0 tok · Output 7 tok')
const usagePill = view.getAllByRole('button')[0]!
expect(usagePill.textContent).toBe('7 tok')
expect(usagePill.getAttribute('aria-label')).toBe('7 tok')
// Output-only activity still fills the dialog's token rows.
fireEvent.click(usagePill)
expect(view.getByRole('dialog').textContent).toContain('Output7 tok')
})
it('includes cache writes in billed input and the cache-hit denominator', () => {
it('includes cache writes in the total and the cache-hit denominator', () => {
const { source } = makeSource({ nodes: [assistant(1, 1)] })
const view = render(<StatsLine {...props(source, {
const view = render(<StatsPills {...props(source, {
tokenUsage: {
uncachedInputTokens: 10,
outputTokens: 7,
@@ -368,16 +427,15 @@ describe('StatsLine', () => {
cacheWriteTokens: 100,
},
})} />)
expect(view.container.textContent)
.toBe('1 turns · 1 steps| Cache hit 45%| Input 200 tok · Output 7 tok')
expect(view.getAllByRole('button')[0]!.textContent).toBe('207 tok·Cache hit 45%')
})
it('renders ZERO times during streaming chunk frames (RFC hard acceptance)', () => {
const { set, source } = makeSource({ nodes: [assistant(1, 1)] })
let renders = 0
function Counting(p: StatsLineProps) {
function Counting(p: StatsPillsProps) {
renders += 1
return <StatsLine {...p} />
return <StatsPills {...p} />
}
render(<Counting {...props(source)} />)
const before = renders
@@ -1,25 +1,17 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { AssistantMarkdown, type AssistantMarkdownProps } from '../src/client/chat/AssistantMarkdown.tsx'
import { StatsLine } from '../src/client/chat/StatsLine.tsx'
import { StatsPills } from '../src/client/chat/StatsPills.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
const t: AssistantMarkdownProps['t'] = makeTranslate(zh, commonZh)
const renderMessageImages: AssistantMarkdownProps['renderMessageImages'] = () => null
/** jsdom has no ResizeObserver; StatsLine watches its row for ellipsis truncation through one. */
class ResizeObserverStub {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
}
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
@@ -40,7 +32,7 @@ describe('render branch tails', () => {
expect(view.container.querySelector('[data-state="ok"]')).not.toBeNull()
})
it('StatsLine falls back to window-node counts and drops every token group without projections', () => {
it('StatsPills falls back to window-node counts and drops the usage pill without projections', () => {
// No sessionStats key → the window fold supplies the counts (the
// assembly-without-the-unit fallback). Node `usage` is deliberately
// ignored: billing rides the durable tokenUsage projection, so an absent
@@ -53,13 +45,15 @@ describe('render branch tails', () => {
const snap = chatSnapshotFixture({ nodes })
const source = { getSnapshot: () => snap, subscribe: () => () => {} }
const view = render(
<StatsLine
<StatsPills
t={t}
useChat={bindSnapshotSelector(source)}
useProjection={() => undefined}
/>,
)
expect(view.container.textContent).toBe('2 轮 · 3 步')
expect(view.container.textContent).toBe('2 轮 3 步')
// Window-fold counts carry no timed figure, so the pill is a static reading.
expect(view.queryAllByRole('button')).toHaveLength(0)
})
it('AssistantMarkdown reasoning as the streaming tail renders the running ring', () => {
@@ -13,10 +13,10 @@ export const zh = {
'hint.goal': '输入目标,智能体将持续执行',
'hint.goal.active': '当前目标进行中。可输入 edit 修改 / pause 暂停 / resume 继续 / clear 清除',
'placeholder.plan': PLAN_NEXT_ACTION_ZH,
'placeholder.default': '发消息或做任务… / 调用指令 @ 文件或对话',
'placeholder.default': '发消息或创建任务, / 调用指令, @ 文件或对话',
'placeholder.unavailable': '会话不可用',
'placeholder.parentOffline': '父会话已离线,无法继续发送;仍可停止当前运行',
'placeholder.hero': '描述你想要构建的内容… / 调用指令 @ 文件或对话',
'placeholder.hero': '描述你想要构建的内容, / 调用指令, @ 文件或对话',
'placeholder.workspace': '选择一个工作区开始',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息',
'input.commands': '指令',
@@ -60,7 +60,7 @@ export const zh = {
'context.aria': '上下文已用 {percent}',
'context.used': '上下文已用',
'context.system': '系统提示词',
'context.tools': '工具',
'context.tools': '工具定义',
'context.messages': '对话消息',
'settings.enter.title': '繁忙时 Enter 键行为',
'settings.enter.description': '仅在智能体运行时生效;Cmd/Ctrl+Enter 使用另一行为',
@@ -177,10 +177,10 @@ export const en = {
'hint.goal': 'describe the objective for a long-running task',
'hint.goal.active': 'goal active — edit / pause / resume / clear',
'placeholder.plan': PLAN_NEXT_ACTION_EN,
'placeholder.default': 'Message or run a task... / commands, @ files or sessions',
'placeholder.default': 'Message or run a task, / commands, @ files or sessions',
'placeholder.unavailable': 'Session unavailable',
'placeholder.parentOffline': 'Parent session offline; sending is unavailable but you can still stop the run',
'placeholder.hero': 'Describe what you want to build... / commands, @ files or sessions',
'placeholder.hero': 'Describe what you want to build, / commands, @ files or sessions',
'placeholder.workspace': 'Choose a workspace to start',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.commands': 'Commands',
@@ -224,7 +224,7 @@ export const en = {
'context.aria': '{percent} of context used',
'context.used': 'of context used',
'context.system': 'System prompt',
'context.tools': 'Tools',
'context.tools': 'Tool definitions',
'context.messages': 'Messages',
'settings.enter.title': 'Enter behavior while busy',
'settings.enter.description': 'Busy only; Cmd/Ctrl+Enter uses the other behavior',
@@ -135,6 +135,8 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
<span className={css.headline}>{headBefore}</span>
<span className={css.percent}>{reading}</span>
<span className={css.headline}>{headAfter}</span>
{/* `~`: usedTokens prefers projectedTokens, whose surface delta is
heuristically repriced on top of the provider-anchored sample. */}
<span className={css.figures}>
{`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
</span>
@@ -449,9 +449,10 @@
gap: 2px;
min-width: 0;
margin-top: 4px;
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
the card's inner controls below. */
padding-left: 20px;
/* figma drew px 8; left nudged +12 so the chip's folder glyph lines up
closer to the card's inner controls below; right keeps the trailing
preset seat off the card edge. */
padding: 0 16px 0 20px;
}
/* Hero: the composer sits inside the session scroll body; center there so
@@ -149,10 +149,11 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
fallback: <HeroFish hovering={hovering} />,
})}
</span>
<span className={css.headlineText}>
{t('hero.headline')}
<span className={css.titleGroup}>
{/* Own element: keeps the headline text addressable apart from the badge. */}
<span>{t('hero.headline')}</span>
<span className={css.previewBadge}>{t('hero.preview')}</span>
</span>
<span className={css.previewBadge}>{t('hero.preview')}</span>
</div>
<div className={css.body}>
{/* The composer remains mounted outside this component. */}
@@ -27,9 +27,15 @@
badge is a product addition outside that source: a mono superscript pill
riding the title's top-right. */
.headline {
display: grid;
grid-template-columns: 34px auto auto;
display: flex;
/* Overflow-driven: the fish and the title group are two wrap units sharing
one centered line while they fit; squeezed, the fish takes its own line
above the group. No breakpoint. */
flex-wrap: wrap;
column-gap: 10px;
/* Wrapped, the fish sits on its own line; more air than the 10px side gap
keeps it reading as a mark above the title, not a squeezed inline glyph. */
row-gap: 12px;
align-items: center;
justify-content: center;
font-size: 26px;
@@ -38,17 +44,24 @@
color: var(--dsw-alias-label-primary);
}
.headlineText {
grid-row: 1;
grid-column: 2;
/* Title text + badge as one wrap unit so the title never breaks mid-text with
the badge stranded beside a fragment. When the badge no longer fits beside
the title it wraps to its own centered line below; while both fit, the group
is content-sized and centering is a no-op, keeping the superscript snug. */
.titleGroup {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: center;
/* Superscript sits 7 from the title. */
column-gap: 7px;
row-gap: 4px;
min-width: 0;
}
.previewBadge {
grid-row: 1;
grid-column: 3;
align-self: start;
align-self: flex-start;
margin-top: 2px;
margin-left: -3px;
padding: 1px 7px 0;
border: 0.5px solid var(--dsw-alias-interactive-bg-hover);
border-radius: 24px;
@@ -64,9 +77,8 @@
/* Keep hover detection on a stationary box while the mark moves within it;
hover rules target the hitbox so a slot-occupant mark swims too. */
.fishHitbox {
grid-row: 1;
grid-column: 1;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
}
@@ -132,7 +144,10 @@
padding-left: 8px;
}
/* Folder + label + chevron — transparent at rest; fill only on hover / open. */
/* Folder + label + chevron — transparent at rest; fill only on hover / open.
Overflow-driven degradation, no breakpoint: the label shrinks with an
ellipsis down to nothing, so folder and chevron always sit snug against
whatever text remains — full text, ellipsized text, or none. */
.workspace {
display: inline-flex;
align-items: center;
@@ -167,6 +182,7 @@
}
.workspaceLabel {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -10,6 +10,13 @@
padding: 0 var(--dsh-composer-side-clearance) 8px;
}
/* A mounted stats row in the composer dock (data-composer-stats, ui-chat
StatsPills) brings its own 4px top pad, so the root's bottom clearance
tightens to keep the drawn B8 rhythm. */
.root:has([data-composer-stats]) {
padding-bottom: 4px;
}
.hero {
/* No bottom pad in the centered hero, but the side clearance must survive:
the hero wrapper is full-width on narrow viewports, so this padding is
@@ -34,12 +41,12 @@
position: relative; /* overlay anchor positioning context */
display: flex;
flex-direction: column;
/* figma Input 75:8208: 12px between the text area and the button row; 10px
top pad on the card before .InputText. */
/* figma Input 75:8208: 12px between the text area and the button row; the
card's top pad before .InputText tightened from the drawn 10 to 8. */
gap: 12px;
width: 100%;
max-width: var(--dsh-composer-card-max-width);
padding-top: 10px;
padding-top: 8px;
/* Input stroke: the light neutral (l2, one step above the menus' l1),
drawn as the elevation hairline so it costs no layout; the shadow is the
soft elevation tier. Fill: the input surface token (elevated in dark). */
@@ -123,8 +130,8 @@
margin-right: 4px;
}
/* The card's 22px corner arc still overlaps the scrollport's top (10px top
pad reaches only y=10): start the thumb's travel below the arc so it never
/* The card's 22px corner arc still overlaps the scrollport's top (8px top
pad reaches only y=8): start the thumb's travel below the arc so it never
pokes outside the capsule. WebKit-path only; Firefox's thin bar has no
track margin, an accepted remainder. */
.scroll::-webkit-scrollbar-track {
@@ -153,11 +160,15 @@
}
/* The contenteditable draft surface (grows with its content; .scroll caps
and scrolls it). figma .InputText 34:10434: pl 16 / pr 12 / pt 4 — the
right pad is 8 here plus .scroll's 4px bar inset. */
and scrolls it). figma .InputText 34:10434 drew pl 16 / pr 12 / pt 4; the
left pad tightened to 14, and the right pad is 8 here plus .scroll's 4px
bar inset. */
.input {
box-sizing: border-box;
padding: 4px 8px 0 16px;
/* Docked floor: one 24px line + 4px top pad = 28 content height; 36 leaves
an 8px breath below the line (the hero keeps its own 52px floor below). */
min-height: 36px;
padding: 4px 8px 0 14px;
font-family: var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
@@ -185,11 +196,16 @@
}
/* figma 34:10434: #ADB2B8 light / #81858C dark — the caption pair exactly.
Right inset mirrors .input's 8px pad (the other 4px sits on .scroll). */
Insets mirror .input's pads (the other right 4px sits on .scroll). One
ellipsized line: a wrapped second line would clip against the docked
36px floor. */
.placeholder {
position: absolute;
inset: 4px 8px auto 16px;
inset: 4px 8px auto 14px;
color: var(--dsw-alias-label-caption);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
user-select: none;
}
@@ -209,6 +225,16 @@
min-height: 52px;
}
/* The hero's 52px floor fits two lines, so its placeholder clamps at two
instead of the docked single line. */
.hero .placeholder {
white-space: normal;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
}
/* Toolbar: attach + Plan + Read-only on the left; model + send on the right
(figma Input_Bottom chrome). */
.row {
@@ -218,8 +244,8 @@
justify-content: space-between;
gap: 12px;
/* 2px moved from the bottom pad to the top: the whole control row sits 2px
lower in the card (it read too high against the textarea) while the card
height and the controls' own centering stay untouched. */
lower in the card (it read too high against the textarea) while the
controls' own centering stays untouched. */
padding: 2px 8px 6px;
min-width: 0;
/* Size container so the chips inside can collapse to icon-only when the
@@ -238,10 +264,10 @@
}
/* figma 75:8208 drew 16 between + and the mode chips and 4 between Plan /
Read-only; the chip gap widened to 12 so the pill chips read as separate
controls. */
Read-only; both gaps settled on 12 so the pill chips read as separate
controls without drifting from the + control. */
.tools {
gap: 16px;
gap: 12px;
}
.modes {
@@ -58,7 +58,7 @@ describe('ContextMeter', () => {
expect(panel.textContent).toContain('25%')
expect(panel.textContent).toContain('上下文已用')
expect(panel.textContent).toContain('系统提示词~120')
expect(panel.textContent).toContain('工具~21.5K')
expect(panel.textContent).toContain('工具定义~21.5K')
expect(panel.textContent).toContain('对话消息~477K')
// The occupancy bar splits into one colored segment per composition row.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(3)
@@ -478,9 +478,9 @@ describe('Enter semantics', () => {
})
it('keeps the owning placeholder or ordinary guidance when whole-queue steering is unavailable', () => {
expect(bench({ running: true }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
expect(bench({ running: true }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
expect(bench({ queue: [row('q-1')] }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
expect(bench({ running: true, queue: [row('q-1')], draft: '消息' }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
expect(bench({
running: true,
queue: [row('q-1')],
@@ -492,7 +492,7 @@ describe('Enter semantics', () => {
running: true,
queue: [row('q-1')],
commandMenuOpen: true,
}).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
}).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
// The steer hint intentionally outranks the plan placeholder: while it
// shows, the whole-queue gesture is genuinely available in plan mode.
expect(bench({
@@ -1007,7 +1007,7 @@ describe('running and lock semantics', () => {
it('disabled state shows the unavailable placeholder; custom placeholder wins', () => {
expect(bench({ disabled: true }).placeholder).toBe('会话不可用')
const live = bench()
expect(live.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
expect(live.placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
const custom = bench({ placeholder: 'Custom placeholder' })
expect(custom.placeholder).toBe('Custom placeholder')
})
@@ -1054,7 +1054,7 @@ describe('running and lock semantics', () => {
expect(entering.placeholder).toBe('描述你的任务以生成计划')
// Pending exit: target is default again.
const leaving = bench({ plan: { active: true, pending: true } })
expect(leaving.placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
expect(leaving.placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
// Owner placeholder outranks the plan swap.
const custom = bench({ plan: { active: true, pending: false }, placeholder: 'Custom placeholder' })
expect(custom.placeholder).toBe('Custom placeholder')
@@ -58,6 +58,8 @@
"@types/react": "~18.3.1",
"@deepseek-ai/cordis": "workspace:^",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@types/react-dom": "~18.3.0",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
@@ -49,12 +49,42 @@
white-space: nowrap;
}
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption tone). */
/* Effort value beside the model name (mock's 'High': same 13/20/500, caption
tone). Adaptive priority: the huge shrink factor makes the effort absorb
all deficit first — it ellipsizes away to nothing before the model name
loses a single pixel, and the chevron stays snug throughout. */
.triggerEffort {
flex: 0 0 auto;
flex-shrink: 1000;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--dsw-alias-label-caption);
}
/* Stand-in for the whole text below the icon-only cut: the settings page's
Models glyph (IconDataOutline16), hidden while any text fits. */
.triggerIcon {
display: none;
flex: 0 0 auto;
}
/* Icon-only floor: past continuous shrink (effort ellipsizes away first, then
the name), a 360px composer row leaves the 45cqw cap too little for a
legible name, so the text yields to the Models glyph. Same anonymous
container as PermissionSelect's 460px cut (InputBar .row); the full name
stays reachable through the trigger's title and aria-label. */
@container (max-width: 360px) {
.triggerIcon {
display: block;
}
.triggerLabel,
.triggerEffort {
display: none;
}
}
.chevron {
flex: 0 0 auto;
color: var(--dsw-alias-label-caption);
@@ -65,11 +95,12 @@
transform: rotate(180deg);
}
/* Portaled to body and placed from the trigger rect by the component (the
Menu primitive's portal rules), so the sidebar and the columns' overflow
clips cannot crop it; z matches the primitive's .portal layer. */
.menu {
position: absolute;
right: 0;
bottom: calc(100% + 8px);
z-index: 20;
position: fixed;
z-index: 1100;
display: flex;
flex-direction: column;
/* Let the visible pane's rows set the card width, but keep pathological ids
@@ -12,14 +12,15 @@
* card; the in-menu strip with Retry remains the catalog-load surface.
*/
import {
useEffect, useId, useMemo, useRef, useState, useSyncExternalStore,
type KeyboardEvent, type FocusEvent,
useEffect, useId, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore,
type CSSProperties, type KeyboardEvent, type FocusEvent,
} from 'react'
import { createPortal } from 'react-dom'
import clsx from 'clsx'
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
import {
IconCheckOutline16, IconChevronDownOutline14, IconChevronRightOutline14,
IconWarningOutline16, Toast,
IconDataOutline16, IconWarningOutline16, Toast,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ModelSelectInjected } from './slots.ts'
@@ -35,6 +36,9 @@ interface EffortChoice {
label: string
}
/** Unplaced portal card: hidden but laid out at a fixed origin so offsetWidth/offsetHeight are real (Menu primitive's measure pass). */
const MEASURE_STYLE: CSSProperties = { visibility: 'hidden', left: 0, top: 0 }
/**
* Render the composer model seat.
* @param props - owner share (locked) + injected face (shared directory
@@ -60,6 +64,8 @@ export function ModelSelect(
const toastSeq = useRef(0)
const rootRef = useRef<HTMLDivElement | null>(null)
const triggerRef = useRef<HTMLButtonElement | null>(null)
const menuRef = useRef<HTMLDivElement | null>(null)
const [menuPos, setMenuPos] = useState<CSSProperties | null>(null)
const itemRefs = useRef<(HTMLButtonElement | null)[]>([])
const id = useId()
@@ -108,12 +114,49 @@ export function ModelSelect(
useEffect(() => {
if (!open) return
const closeOutside = (event: MouseEvent): void => {
if (!rootRef.current?.contains(event.target as Node)) setOpen(false)
// The portaled card is outside the trigger subtree; check both.
if (rootRef.current?.contains(event.target as Node) === true) return
if (menuRef.current?.contains(event.target as Node) === true) return
setOpen(false)
}
document.addEventListener('mousedown', closeOutside)
return () => { document.removeEventListener('mousedown', closeOutside) }
}, [open])
// Portaled placement (the Menu primitive's portal rules: fixed from the
// anchor rect, measured before paint, clamped inside the viewport): above
// the trigger, right edges aligned. Depends on pane and directory state
// because pane switches and async catalog loads resize the card.
/* jscpd:ignore-start -- deliberate mirror of ui-primitives useAnchoredPosition:
that hook only places from the anchor's LEFT edge, while this card aligns
right edges (x = rect.right - width), so the measure-and-clamp plumbing repeats. */
useLayoutEffect(() => {
if (!open) { setMenuPos(null); return }
const place = (): void => {
/* v8 ignore next 2 -- the trigger ref is attached whenever the menu is open. */
const rect = triggerRef.current?.getBoundingClientRect()
if (rect === undefined) return
const MARGIN = 12
const lw = menuRef.current?.offsetWidth ?? 0
const lh = menuRef.current?.offsetHeight ?? 0
let x = rect.right - lw
let y = rect.top - 8 - lh
if (lw > 0) x = Math.min(Math.max(x, MARGIN), window.innerWidth - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, MARGIN), window.innerHeight - lh - MARGIN)
setMenuPos({ left: x, top: y })
}
// First run measures the hidden pre-render (same commit as `open`), so
// the card lands placed before anything paints.
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open, pane, state])
/* jscpd:ignore-end */
if (!available) return null
const show = (): void => {
@@ -152,7 +195,10 @@ export function ModelSelect(
}
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
if (event.relatedTarget instanceof Node && rootRef.current?.contains(event.relatedTarget)) return
if (event.relatedTarget instanceof Node && (
rootRef.current?.contains(event.relatedTarget) === true
|| menuRef.current?.contains(event.relatedTarget) === true
)) return
close()
}
@@ -232,15 +278,21 @@ export function ModelSelect(
}
}}
>
<IconDataOutline16 className={css.triggerIcon} size={16} />
<span className={css.triggerLabel}>{modelLabel}</span>
{effortLabel !== undefined && <span className={css.triggerEffort}>{effortLabel}</span>}
<IconChevronDownOutline14 className={clsx(css.chevron, open && css.chevronOpen)} />
</button>
{open && (
{/* Portaled to body (Menu primitive's portal mode) so the sidebar and
column overflow clips cannot crop the card; synthetic events still
bubble through this React subtree, keeping onKeyDown/onBlur live. */}
{open && createPortal(
<div
ref={menuRef}
id={`${id}-menu`}
className={css.menu}
style={menuPos ?? MEASURE_STYLE}
role="menu"
aria-label={t('menu.aria')}
aria-busy={state.status === 'loading' || busy}
@@ -349,7 +401,8 @@ export function ModelSelect(
))}
</>
)}
</div>
</div>,
document.body,
)}
{toast !== null && (
<Toast
@@ -200,6 +200,43 @@ describe('ModelSelect reasoning effort', () => {
expect(screen.queryByRole('button', { name: '重试' })).toBeNull()
})
it('portals the placed menu card to body and closes only on truly-outside mousedown', () => {
const offsetWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', { configurable: true, get: () => 200 })
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 300 })
try {
const { container } = render(<ModelSelect
locked={false}
available
directory={createSnapshotStore(state())}
load={vi.fn()}
select={vi.fn().mockResolvedValue(true)}
t={t}
/>)
const trigger = screen.getByRole('button', { name: /选择模型/ })
fireEvent.click(trigger)
const menu = screen.getByRole('menu')
// Outside the composer subtree — column overflow clips cannot crop it.
expect(container.contains(menu)).toBe(false)
expect(menu.parentElement).toBe(document.body)
// jsdom anchor rects are all zero, so the measured 200x300 card clamps
// to the 12px viewport margin on both axes.
expect(menu.style.left).toBe('12px')
expect(menu.style.top).toBe('12px')
// Interactions inside the trigger subtree or the portaled card stay open.
fireEvent.mouseDown(menu)
fireEvent.mouseDown(trigger)
fireEvent.blur(trigger, { relatedTarget: menu })
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.mouseDown(document.body)
expect(screen.queryByRole('menu')).toBeNull()
} finally {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', offsetWidth)
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
}
})
it('renders no Agent-bound control for an addressed subagent session', () => {
const load = vi.fn()
render(<ModelSelect
+1 -1
View File
@@ -98,7 +98,7 @@ export function Menu({ open, anchor, items, selectedId, selectedIds, onSelect, o
compact?: boolean
selection?: 'check' | 'fill'
getAnchorRect?: () => DOMRect | null
className?: string
className?: string | undefined
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const listRef = useRef<HTMLDivElement>(null)
@@ -5,7 +5,7 @@
.toast {
position: fixed;
top: 120px;
top: 40px;
left: 50%;
/* Above the 1000 the image lightbox backdrop uses: a failure reported while
a preview is open must stay readable. */
@@ -15,7 +15,12 @@
display: flex;
align-items: center;
gap: 10px;
max-width: min(560px, calc(100vw - 48px));
/* Fixed boxes with `left` set shrink-to-fit against the space RIGHT of
`left` (the -50% translate happens after sizing), so an anchored banner
near the window edge would wrap early. max-content sizes the box from
its text alone, capped by the max-width below. */
width: max-content;
max-width: min(640px, calc(100vw - 48px));
padding: 12px 16px;
border-radius: 14px;
background: var(--dsw-alias-button-contrast-fill);
@@ -778,6 +778,17 @@ export const IconClockOutline16 = ({ size = 16, className }: IconProps) => (
</svg>
)
/** Thin-stroke gauge: dial arc open at the bottom, filled hub, square-cut needle to the upper right.
* The dial center sits at y=8.75, not 8: the bottom opening leaves the glyph top-heavy, and the
* 0.75 drop optically centers the drawn extent in the 16 box. */
export const IconGaugeOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.49 13.26A6.375 6.375 0 1 1 12.51 13.26" stroke="currentColor" strokeWidth="1.25" />
<path d="M8 8.75L11.4 5.35" stroke="currentColor" strokeWidth="1.25" />
<circle cx="8" cy="8.75" r="1.55" fill="currentColor" />
</svg>
)
/** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */
export const IconSendOutline14 = ({ size = 14, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('ic_ds_ icon set', () => {
it('exports the full icon set (46 deepsuite + 21 figma extracts + seven product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(74)
it('exports the full icon set (46 deepsuite + 21 figma extracts + eight product glyphs outside those sets)', () => {
expect(iconNames.length).toBe(75)
})
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
@@ -503,7 +503,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
slotInject: '',
declaredBy: 'an entry in \'conversation.composer.bar\' (client-ui-conversation), so it exists while that entry is mounted',
occupants: [
'client-ui-chat StatsLine id \'stats\'',
'client-ui-chat StatsPills id \'stats\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
+6
View File
@@ -3086,9 +3086,15 @@ importers:
'@types/react':
specifier: ~18.3.1
version: 18.3.31
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/[email protected])
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1([email protected])
packages/client/ui-open-in-app:
devDependencies:
+7 -2
View File
@@ -28,7 +28,7 @@
- 'button "Failed Bash Error: tool call aborted before dispatch"':
- img
- text: "Failed Bash Error: tool call aborted before dispatch"
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -38,4 +38,9 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 10 tok · Output 10 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "20 tok · Cache hit 0%":
- img
- text: 20 tokCache hit 0%
@@ -116,7 +116,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -127,4 +127,9 @@
- img
- button "0% of context used"
- button "Send message" [disabled]
- text: 3 turns · 7 steps LLM {{duration}} · Tool call {{duration}} Cache hit 77% Input 66.5K tok · Output 321 tok
- button "3 turns 7 steps":
- img
- text: 3 turns 7 steps
- button "66.8K tok · Cache hit 77%":
- img
- text: 66.8K tokCache hit 77%
@@ -46,7 +46,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -57,4 +57,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "7.8K tok · Cache hit 99%":
- img
- text: 7.8K tokCache hit 99%
@@ -38,7 +38,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -49,4 +49,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "7.8K tok · Cache hit 99%":
- img
- text: 7.8K tokCache hit 99%
@@ -46,7 +46,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -57,4 +57,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "7.8K tok · Cache hit 99%":
- img
- text: 7.8K tokCache hit 99%
@@ -38,7 +38,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}."
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -49,4 +49,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "7.8K tok · Cache hit 99%":
- img
- text: 7.8K tokCache hit 99%
@@ -40,7 +40,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -51,4 +51,9 @@
- img
- button "1% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 51% Input 17.2K tok · Output 155 tok
- button "1 turns 2 steps · {{throughput}} tok/s":
- img
- text: 1 turns 2 steps{{throughput}} tok/s
- button "17.3K tok · Cache hit 51%":
- img
- text: 17.3K tokCache hit 51%
@@ -50,7 +50,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -61,4 +61,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok
- button "1 turns 2 steps · {{throughput}} tok/s":
- img
- text: 1 turns 2 steps{{throughput}} tok/s
- button "15.8K tok · Cache hit 99%":
- img
- text: 15.8K tokCache hit 99%
@@ -34,7 +34,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -45,4 +45,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.7K tok · Output 111 tok
- button "1 turns 2 steps · {{throughput}} tok/s":
- img
- text: 1 turns 2 steps{{throughput}} tok/s
- button "15.8K tok · Cache hit 99%":
- img
- text: 15.8K tokCache hit 99%
@@ -228,7 +228,7 @@
- text: {{clock}}
- button "Back to bottom":
- img
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -239,4 +239,9 @@
- img
- button "9% of context used"
- button "Send message" [disabled]
- text: 2 turns · 12 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 91% Input 113K tok · Output 2.4K tok
- button "2 turns 12 steps · {{throughput}} tok/s":
- img
- text: 2 turns 12 steps{{throughput}} tok/s
- button "115K tok · Cache hit 91%":
- img
- text: 115K tokCache hit 91%
@@ -116,7 +116,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -127,4 +127,9 @@
- img
- button "9% of context used"
- button "Send message" [disabled]
- text: 2 turns · 12 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 91% Input 113K tok · Output 2.4K tok
- button "2 turns 12 steps · {{throughput}} tok/s":
- img
- text: 2 turns 12 steps{{throughput}} tok/s
- button "115K tok · Cache hit 91%":
- img
- text: 115K tokCache hit 91%
@@ -29,7 +29,7 @@
- img
- text: Standard mode
- img
- textbox "Describe what you want to build... / commands, @ files or sessions":
- textbox "Describe what you want to build, / commands, @ files or sessions":
- paragraph
- button "Commands":
- img
@@ -29,7 +29,7 @@
- img
- text: Standard mode
- img
- textbox "Describe what you want to build... / commands, @ files or sessions"
- textbox "Describe what you want to build, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -42,7 +42,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -53,4 +53,9 @@
- img
- button "8% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "10K tok · Cache hit 99.5%":
- img
- text: 10K tokCache hit 99.5%
@@ -34,7 +34,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -45,4 +45,9 @@
- img
- button "8% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "10K tok · Cache hit 99.5%":
- img
- text: 10K tokCache hit 99.5%
@@ -39,7 +39,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -49,4 +49,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -35,7 +35,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -45,4 +45,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}}
- button "1 turns 1 steps":
- img
- text: 1 turns 1 steps
@@ -25,7 +25,7 @@
- status:
- text: This turn failedAPI key is invalid
- code: AUTH
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -35,4 +35,5 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps
- img
- text: 1 turns 1 steps
@@ -24,7 +24,7 @@
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -27,7 +27,7 @@
- status:
- text: This turn failedupstream 503
- code: SERVER
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -37,4 +37,5 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
- text: 1 turns · 1 steps
- img
- text: 1 turns 1 steps
@@ -44,7 +44,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -55,4 +55,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "7.9K tok · Cache hit 99%":
- img
- text: 7.9K tokCache hit 99%
@@ -34,7 +34,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
- textbox "Message or run a task... / commands, @ files or sessions"
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -45,4 +45,9 @@
- img
- button "6% of context used"
- button "Send message" [disabled]
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 79 tok
- button "1 turns 1 steps · {{throughput}} tok/s":
- img
- text: 1 turns 1 steps{{throughput}} tok/s
- button "7.9K tok · Cache hit 99%":
- img
- text: 7.9K tokCache hit 99%
@@ -24,7 +24,7 @@
- text: Context injection @deepseek-ai/dsh-system-prompt
- paragraph: partial
- status: Deep diving...
- textbox "Message or run a task... / commands, @ files or sessions":
- textbox "Message or run a task, / commands, @ files or sessions":
- paragraph: Queue this follow-up while the current turn is running.
- button "Commands":
- img
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff. Show more