diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml index aaba9b1db2..892e3a8fc0 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md index d62f7dccd5..f96257243b 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md index 6d2bb624ac..7365d5d816 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-projected-token-usage-and-request-context.zh.md @@ -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 或数据路径。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index b7a8cc094a..eb701400a4 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 7f5b948f90..842dfb0218 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 8716b25584..6ab64c2595 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -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。 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml new file mode 100644 index 0000000000..3fae385422 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md new file mode 100644 index 0000000000..818c3c156f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md @@ -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'`. diff --git a/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md new file mode 100644 index 0000000000..8a74cddd31 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md @@ -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'`。 diff --git a/apps/web/tests/built-boot.expected.e2e.ts b/apps/web/tests/built-boot.expected.e2e.ts index 275cda21d5..d7292382df 100644 --- a/apps/web/tests/built-boot.expected.e2e.ts +++ b/apps/web/tests/built-boot.expected.e2e.ts @@ -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 diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts index f7da8f088a..565eaa3524 100644 --- a/apps/web/tests/command-image-envelope.expected.e2e.ts +++ b/apps/web/tests/command-image-envelope.expected.e2e.ts @@ -21,7 +21,7 @@ async function freshComposer(): Promise { fireEvent.click(start) return await waitFor(() => { const surface = document.querySelector( - '[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 diff --git a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md index 3b43a6c687..1bc1752380 100644 --- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md +++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md @@ -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 diff --git a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md index dc24d0bebf..92887b13f2 100644 --- a/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation-expanded.expected.md @@ -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 diff --git a/apps/web/tests/expected/github-ready-review/conversation.expected.md b/apps/web/tests/expected/github-ready-review/conversation.expected.md index c1d92a5aa7..aa35d67e04 100644 --- a/apps/web/tests/expected/github-ready-review/conversation.expected.md +++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md @@ -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 diff --git a/apps/web/tests/expected/goal-command-presentation/ui.expected.md b/apps/web/tests/expected/goal-command-presentation/ui.expected.md index cda91117e4..d5fddccca9 100644 --- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md +++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md @@ -16,7 +16,7 @@ - img - img - text: "goal No goal is currently set. Usage: /goal [|clear|edit |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": diff --git a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md index b43af467c9..8cccd6e2a7 100644 --- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md +++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md @@ -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 diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index bd51c1ecd9..3e00a67cb0 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -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 diff --git a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md index 2581f08dd3..e042455176 100644 --- a/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md +++ b/apps/web/tests/expected/markdown-inline-code-links/ui.expected.md @@ -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 diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md index 19314b2828..20e51e6c63 100644 --- a/apps/web/tests/expected/math-rendering/ui.expected.md +++ b/apps/web/tests/expected/math-rendering/ui.expected.md @@ -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 diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md index 8187c93074..7ea2707435 100644 --- a/apps/web/tests/expected/reference-composer/order.expected.md +++ b/apps/web/tests/expected/reference-composer/order.expected.md @@ -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": diff --git a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md index b83c67251a..f14b04d9a9 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui-expanded.expected.md @@ -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% diff --git a/apps/web/tests/expected/skill-user-invoke/ui.expected.md b/apps/web/tests/expected/skill-user-invoke/ui.expected.md index 8af7e9b7da..e0901950d2 100644 --- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md +++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md @@ -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% diff --git a/apps/web/tests/expected/stats-paged-history/ui.expected.md b/apps/web/tests/expected/stats-paged-history/ui.expected.md index 462cab160d..6a3f9ecbb6 100644 --- a/apps/web/tests/expected/stats-paged-history/ui.expected.md +++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md @@ -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 diff --git a/apps/web/tests/expected/steer-all/mid-steer.expected.md b/apps/web/tests/expected/steer-all/mid-steer.expected.md index 5e8c46a9d2..11e83471a3 100644 --- a/apps/web/tests/expected/steer-all/mid-steer.expected.md +++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md @@ -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": diff --git a/apps/web/tests/expected/steer-all/settled-expanded.expected.md b/apps/web/tests/expected/steer-all/settled-expanded.expected.md index 5ce25967c9..b1644d07d5 100644 --- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md +++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md @@ -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% diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md index cb98d44fac..acbaf1b148 100644 --- a/apps/web/tests/expected/steer-all/settled.expected.md +++ b/apps/web/tests/expected/steer-all/settled.expected.md @@ -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% diff --git a/apps/web/tests/feedback-release.e2e.ts b/apps/web/tests/feedback-release.e2e.ts index e4659aa622..d6df51d270 100644 --- a/apps/web/tests/feedback-release.e2e.ts +++ b/apps/web/tests/feedback-release.e2e.ts @@ -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) diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts index f9cd46b60c..9d811e5c8b 100644 --- a/apps/web/tests/goal-bar.e2e.ts +++ b/apps/web/tests/goal-bar.e2e.ts @@ -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') diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index 886421262f..e35486abfe 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -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( - '[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( - '[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( - '[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 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index 6052ea0a88..282aa0b1a9 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -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 (/workspace) — the proof the // send went through workspace materialization rather than a bare diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index 5f5c89ead4..c826d5aff2 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -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) diff --git a/apps/web/tests/plan-review.e2e.ts b/apps/web/tests/plan-review.e2e.ts index ff9e3af4ee..b9ae32cec0 100644 --- a/apps/web/tests/plan-review.e2e.ts +++ b/apps/web/tests/plan-review.e2e.ts @@ -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') { diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts index cea8d401da..6f8d2aab97 100644 --- a/apps/web/tests/preview-boot.e2e.ts +++ b/apps/web/tests/preview-boot.e2e.ts @@ -314,7 +314,7 @@ async function bootPreview(origin: string, browser: Browser): Promise { 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 () => { diff --git a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md index 81c1e347a7..1ca602fbd7 100644 --- a/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md +++ b/apps/web/tests/snapshots/streaming-fence-highlight/mid-stream.expected.md @@ -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": diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts index 1404402b4f..613eb4a7d0 100644 --- a/apps/web/tests/startup-auto-selection.e2e.ts +++ b/apps/web/tests/startup-auto-selection.e2e.ts @@ -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) diff --git a/apps/web/tests/stats-paged-history.e2e.ts b/apps/web/tests/stats-paged-history.e2e.ts index 6e6174232c..45e5d3de2d 100644 --- a/apps/web/tests/stats-paged-history.e2e.ts +++ b/apps/web/tests/stats-paged-history.e2e.ts @@ -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 diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index d9b66749de..54e9960836 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -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() diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 99032e7dfa..63486fe752 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -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 }) } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 55fe22e81b..982603c94c 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -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 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index d0501ed0cd..f16d1c83d9 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -181,6 +181,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, }} align="start" portal + className={css.menuAnchor} anchor={( )} diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css index 71de9ec4bf..ac4de3905e 100644 --- a/packages/client/ui-attachment/src/FileCard.module.css +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -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; diff --git a/packages/client/ui-attachment/src/FileCard.tsx b/packages/client/ui-attachment/src/FileCard.tsx index d5634b7d9a..e10c91993c 100644 --- a/packages/client/ui-attachment/src/FileCard.tsx +++ b/packages/client/ui-attachment/src/FileCard.tsx @@ -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} > - - - + {state === 'uploading' && ( diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index b4a8eba014..1c9c72aa2d 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -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 { diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 25169bc0c8..2d8de50561 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -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)) diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.module.css b/packages/client/ui-chat/src/client/chat/StatsLine.module.css deleted file mode 100644 index 29c0bc3aab..0000000000 --- a/packages/client/ui-chat/src/client/chat/StatsLine.module.css +++ /dev/null @@ -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 */ -} diff --git a/packages/client/ui-chat/src/client/chat/StatsLine.tsx b/packages/client/ui-chat/src/client/chat/StatsLine.tsx deleted file mode 100644 index 170ffec956..0000000000 --- a/packages/client/ui-chat/src/client/chat/StatsLine.tsx +++ /dev/null @@ -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() - 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 - 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(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 ( - -
- {groups.map((group, i) => ( - - {i > 0 && <>|{' '}} - {group} - - ))} -
-
- ) -}) - -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 -}) diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.module.css b/packages/client/ui-chat/src/client/chat/StatsPills.module.css new file mode 100644 index 0000000000..22f39a1f26 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/StatsPills.module.css @@ -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; +} diff --git a/packages/client/ui-chat/src/client/chat/StatsPills.tsx b/packages/client/ui-chat/src/client/chat/StatsPills.tsx new file mode 100644 index 0000000000..091352dac1 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx @@ -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() + 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 + 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, '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 = ( + + {counts} + {tps !== null && ( + <> + · + {tps} + + )} + + ) + // A window without one timed figure has no dialog rows to show, so the pill + // stays a plain reading instead of a button opening an empty dialog. + if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && stats.decodeMs <= 0) { + return ( + + + + {label} + + + ) + } + return ( + + + {open && createPortal( +
+
+ + + {t('stats.dialog.title')} + +
+
+
+ {stats.llmMs > 0 && ( + <> +
{t('stats.dialog.llmTime')}
+
{formatDuration(stats.llmMs, t)}
+ + )} + {stats.toolMs > 0 && ( + <> +
{t('stats.dialog.toolTime')}
+
{formatDuration(stats.toolMs, t)}
+ + )} + {stats.ttftSteps > 0 && ( + <> +
{t('stats.dialog.ttft')}
+
{formatDuration(stats.ttftMs / stats.ttftSteps, t)}
+ + )} + {stats.decodeMs > 0 && ( + <> +
{t('stats.dialog.speed')}
+
{t('message.tokensPerSecond', { + tps: formatTokensPerSecond(stats.decodeTokens / (stats.decodeMs / 1_000)), + })}
+ + )} +
+
, + document.body, + )} + + ) +} + +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 ( + + + {open && createPortal( +
+
+ + + {t('stats.dialog.usageTitle')} + + {exactCount(total, t)} +
+
+ {/* jscpd:ignore-start -- the session-total bucket rows deliberately mirror + TurnUsagePanel's per-turn dl: same skin, different data contract (all + buckets always present here; per-turn fields are optional). */} +
+ {cacheHit !== null && ( + <> +
{t('message.turnUsage.cacheHit')}
+
{`${cacheHit}%`}
+ + )} +
{t('message.turnUsage.input')}
+
{exactCount(usage.uncachedInputTokens, t)}
+
{t('message.turnUsage.cacheRead')}
+
{exactCount(usage.cacheReadTokens, t)}
+
{t('message.turnUsage.cacheWrite')}
+
{exactCount(usage.cacheWriteTokens, t)}
+
{t('message.turnUsage.output')}
+
{exactCount(usage.outputTokens, t)}
+
+ {/* jscpd:ignore-end */} +
, + document.body, + )} + + ) +} + +export const StatsPills = memo(function StatsPills({ useChat, useProjection, 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 ( +
+ {stats.steps > 0 && ( + { setOpenPill(open ? 'time' : null) }, + }} + /> + )} + {hasTokens && ( + { setOpenPill(open ? 'usage' : null) }, + }} + /> + )} +
+ ) +}) diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css index 7cc7c639eb..52058f8be7 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.module.css @@ -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; -} diff --git a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx index b3cd2c12a7..0d64ff9e08 100644 --- a/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnUsagePanel.tsx @@ -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 - panelRef: MutableRefObject - 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(null) - const panelRef = useRef(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(
-
- +
+ {t('message.turnUsage.title')} - {formatExactCount(usage.totalTokens, t)} + {formatExactCount(usage.totalTokens, t)}
-
-
+
+
{routes !== '' && ( <>
{t('message.turnUsage.model')}
-
{routes}
+
{routes}
)} {cacheHit !== null && ( @@ -164,7 +111,7 @@ export function TurnUsagePanel({ usage, t }: TurnUsagePanelProps) {
{formatExactCount(usage.outputTokens, t)} {usage.reasoningTokens !== undefined && ( - + {t('message.turnUsage.reasoning', { tokens: formatExactCount(usage.reasoningTokens, t) })} )} @@ -199,19 +146,19 @@ export function TurnTimePanel({ runMs, tokensPerSecond, ttftMs, t }: TurnTimePan {open && createPortal(
-
- +
+ {t('message.turnTime.title')}
-
-
+
+
{t('message.turnTime.duration')}
{formatRunDuration(runMs, t)}
{tokensPerSecond !== undefined && ( diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.module.css b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css new file mode 100644 index 0000000000..cc30711a68 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.module.css @@ -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; +} diff --git a/packages/client/ui-chat/src/client/chat/stat-dialog.ts b/packages/client/ui-chat/src/client/chat/stat-dialog.ts new file mode 100644 index 0000000000..10c1071d3c --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/stat-dialog.ts @@ -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 + panelRef: MutableRefObject + 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 { + const [ownOpen, setOwnOpen] = useState(false) + const open = controlled?.open ?? ownOpen + const setOpen = controlled?.setOpen ?? setOwnOpen + const rootRef = useRef(null) + const panelRef = useRef(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 } +} diff --git a/packages/client/ui-chat/src/client/contract/snapshot.ts b/packages/client/ui-chat/src/client/contract/snapshot.ts index c7544d4a16..0d02198811 100644 --- a/packages/client/ui-chat/src/client/contract/snapshot.ts +++ b/packages/client/ui-chat/src/client/contract/snapshot.ts @@ -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 diff --git a/packages/client/ui-chat/src/client/contract/turn-metrics.ts b/packages/client/ui-chat/src/client/contract/turn-metrics.ts index 5880a92af5..a9f4dbc7c0 100644 --- a/packages/client/ui-chat/src/client/contract/turn-metrics.ts +++ b/packages/client/ui-chat/src/client/contract/turn-metrics.ts @@ -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, diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 04cb38bc94..fb91233786 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -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() private readonly finalizedContributions = new Map() diff --git a/packages/client/ui-chat/src/client/locale.ts b/packages/client/ui-chat/src/client/locale.ts index 09011977b0..e426a56a14 100644 --- a/packages/client/ui-chat/src/client/locale.ts +++ b/packages/client/ui-chat/src/client/locale.ts @@ -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', diff --git a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx index d9a77dd63b..5991449f44 100644 --- a/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-branch-tails.client.spec.tsx @@ -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( - 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('缓存命中') }) }) diff --git a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx index e35aa297dc..f3980bef39 100644 --- a/packages/client/ui-chat/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-stats.client.spec.tsx @@ -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): StatsLineProps['useProjection'] { + function projections(values: Record): StatsPillsProps['useProjection'] { return (key: string) => values[key] } function props( source: { getSnapshot(): ChatSnapshot; subscribe(fn: () => void): () => void }, values: Record = { 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() - 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() - // 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() + // 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() 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() - expect(view.container.textContent).toContain(expected) + const view = render() + 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() - 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() + 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() - 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() + + 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() - 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() + + 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() - 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() + 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()).not.toThrow() + it('keeps at most one dialog open: a sibling pill click swaps, never stacks', () => { + const { source } = makeSource({ nodes: [timedStep()] }) + const view = render() + 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() + 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() // 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() - expect(view.container.textContent).toBe('1 turns · 1 steps') + const view = render() + 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() - 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() 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() - 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() - 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() - 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() - 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( { 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 + return } render() const before = renders diff --git a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx index c74cf3bd63..a778ba7bfb 100644 --- a/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx +++ b/packages/client/ui-chat/tests/gate-branch-tails.client.spec.tsx @@ -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( - 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', () => { diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 2369dafe4f..56971eb93e 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -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', diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index 0caaf28cb8..dc63dac9e3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx @@ -135,6 +135,8 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) { {headBefore} {reading} {headAfter} + {/* `~`: usedTokens prefers projectedTokens, whose surface delta is + heuristically repriced on top of the provider-anchored sample. */} {`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`} diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index 0c7e538c1d..f988537220 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -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 diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 6c4820ed4f..9ac7ae573b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -149,10 +149,11 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) { fallback: , })} - - {t('hero.headline')} + + {/* Own element: keeps the headline text addressable apart from the badge. */} + {t('hero.headline')} + {t('hero.preview')} - {t('hero.preview')}
{/* The composer remains mounted outside this component. */} diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 6c5053e9c6..d47e0bfe2f 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -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; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index b7561bbfd8..abd5911c90 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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 { diff --git a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx index 75b9a59d0c..90d69e64cc 100644 --- a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx +++ b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx @@ -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) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 8e83ef05b8..da0af4e8f1 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -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') diff --git a/packages/client/ui-model-selection/package.json b/packages/client/ui-model-selection/package.json index cbee5f3884..ac7104234d 100644 --- a/packages/client/ui-model-selection/package.json +++ b/packages/client/ui-model-selection/package.json @@ -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:^", diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.module.css b/packages/client/ui-model-selection/src/client/ModelSelect.module.css index 85b67ba496..b2cb715287 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.module.css +++ b/packages/client/ui-model-selection/src/client/ModelSelect.module.css @@ -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 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index e910651e86..e9fc0195ef 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -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(null) const triggerRef = useRef(null) + const menuRef = useRef(null) + const [menuPos, setMenuPos] = useState(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): 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( } }} > + {modelLabel} {effortLabel !== undefined && {effortLabel}} - {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( +
, + document.body, )} {toast !== null && ( { 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() + 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( DOMRect | null - className?: string + className?: string | undefined }) { const rootRef = useRef(null) const listRef = useRef(null) diff --git a/packages/client/ui-primitives/src/Toast.module.css b/packages/client/ui-primitives/src/Toast.module.css index 0af6ec2151..793cbe23a1 100644 --- a/packages/client/ui-primitives/src/Toast.module.css +++ b/packages/client/ui-primitives/src/Toast.module.css @@ -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); diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index b2d52182cf..4afc31089f 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -778,6 +778,17 @@ export const IconClockOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** 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) => ( + + + + + +) + /** ic_send_outline_14 (figma extract): thin-stroke upward send arrow. */ export const IconSendOutline14 = ({ size = 14, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/tests/icons.client.spec.tsx b/packages/client/ui-primitives/tests/icons.client.spec.tsx index e29bb2ce14..1b9a9c7773 100644 --- a/packages/client/ui-primitives/tests/icons.client.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.client.spec.tsx @@ -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) => { diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index 23582cf6e3..eff4345ddf 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -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}', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6b6037f02..b205881126 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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/react@18.3.31) react: specifier: ^18.2.0 version: 18.3.1 + react-dom: + specifier: ^18.2.0 + version: 18.3.1(react@18.3.1) packages/client/ui-open-in-app: devDependencies: diff --git a/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md index 02083de5c3..4af682fdb2 100644 --- a/snapshots/web/bash-abort-row/ui.expected.md +++ b/snapshots/web/bash-abort-row/ui.expected.md @@ -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% diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md index 955c1ec28e..aa741273ff 100644 --- a/snapshots/web/cordis-tool-round/ui.expected.md +++ b/snapshots/web/cordis-tool-round/ui.expected.md @@ -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% diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md index f5ce4e137a..6939707ba4 100644 --- a/snapshots/web/feedback-command/ack-expanded.expected.md +++ b/snapshots/web/feedback-command/ack-expanded.expected.md @@ -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% diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md index d702f94f3a..75bd8bad1b 100644 --- a/snapshots/web/feedback-command/ack.expected.md +++ b/snapshots/web/feedback-command/ack.expected.md @@ -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% diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md index f5ce4e137a..6939707ba4 100644 --- a/snapshots/web/feedback-release/ack-expanded.expected.md +++ b/snapshots/web/feedback-release/ack-expanded.expected.md @@ -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% diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md index d702f94f3a..75bd8bad1b 100644 --- a/snapshots/web/feedback-release/ack.expected.md +++ b/snapshots/web/feedback-release/ack.expected.md @@ -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% diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md index 0a8849c1d1..cbddcb3d63 100644 --- a/snapshots/web/file-upload-round/ui.expected.md +++ b/snapshots/web/file-upload-round/ui.expected.md @@ -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% diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md index 70b8e58bf5..0d877cc2e6 100644 --- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md +++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md @@ -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% diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md index 2274d37a76..61079e8d61 100644 --- a/snapshots/web/fresh-round-trip/ui.expected.md +++ b/snapshots/web/fresh-round-trip/ui.expected.md @@ -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% diff --git a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md index f26864b10d..c88d6d3c40 100644 --- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md @@ -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% diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md index b26c923482..bcd881ffef 100644 --- a/snapshots/web/goal-multi-turn-actions/ui.expected.md +++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md @@ -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% diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md index 400ac58929..aa7c6a7728 100644 --- a/snapshots/web/lifecycle-chrome/hero.expected.md +++ b/snapshots/web/lifecycle-chrome/hero.expected.md @@ -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 diff --git a/snapshots/web/lifecycle-chrome/plan-active.expected.md b/snapshots/web/lifecycle-chrome/plan-active.expected.md index ccb4e102e5..bda1b957d8 100644 --- a/snapshots/web/lifecycle-chrome/plan-active.expected.md +++ b/snapshots/web/lifecycle-chrome/plan-active.expected.md @@ -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": diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md index a2285c7a0d..f7e902be44 100644 --- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md @@ -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% diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md index fb568b1c26..47c78b4156 100644 --- a/snapshots/web/lifecycle-chrome/reloaded.expected.md +++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md @@ -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% diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md index 58a183ae56..906f3a9ed3 100644 --- a/snapshots/web/live-interactions/cancel-expanded.expected.md +++ b/snapshots/web/live-interactions/cancel-expanded.expected.md @@ -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 diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md index 4598bb6f79..d50dc2c162 100644 --- a/snapshots/web/live-interactions/cancel.expected.md +++ b/snapshots/web/live-interactions/cancel.expected.md @@ -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 diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md index cf060b5584..d6cd35dcb3 100644 --- a/snapshots/web/live-interactions/error-auth.expected.md +++ b/snapshots/web/live-interactions/error-auth.expected.md @@ -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 diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md index 48a4ca5b5f..3dfa3da484 100644 --- a/snapshots/web/live-interactions/loading.expected.md +++ b/snapshots/web/live-interactions/loading.expected.md @@ -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": diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md index c093363f43..a9c40b178e 100644 --- a/snapshots/web/live-interactions/retry-exhausted.expected.md +++ b/snapshots/web/live-interactions/retry-exhausted.expected.md @@ -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 diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md index 9cfda2b0ae..6c3591cc67 100644 --- a/snapshots/web/live-interactions/retry-expanded.expected.md +++ b/snapshots/web/live-interactions/retry-expanded.expected.md @@ -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% diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md index 08061e9718..21ce478e78 100644 --- a/snapshots/web/live-interactions/retry.expected.md +++ b/snapshots/web/live-interactions/retry.expected.md @@ -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% diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md index 9e1a1be89e..cebb53ebac 100644 --- a/snapshots/web/live-interactions/running-draft.expected.md +++ b/snapshots/web/live-interactions/running-draft.expected.md @@ -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 diff --git a/snapshots/web/message-actions/ui.expected.md b/snapshots/web/message-actions/ui.expected.md index 9070601856..58ae5e04f7 100644 --- a/snapshots/web/message-actions/ui.expected.md +++ b/snapshots/web/message-actions/ui.expected.md @@ -64,7 +64,7 @@ - img - text: Ran for {{duration}} - text: 7/25 {{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": @@ -74,4 +74,9 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 2 turns · 3 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 7.8K tok · Output 103 tok +- button "2 turns 3 steps · {{throughput}} tok/s": + - img + - text: 2 turns 3 steps{{throughput}} tok/s +- button "7.9K tok · Cache hit 98%": + - img + - text: 7.9K tokCache hit 98% diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md index b1d4efd49f..9ad6fdf256 100644 --- a/snapshots/web/minimal-preset/ui.expected.md +++ b/snapshots/web/minimal-preset/ui.expected.md @@ -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": @@ -50,4 +50,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 8 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "28 tok · Cache hit 0%": + - img + - text: 28 tokCache hit 0% diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md index 0e7c1a3cda..43c4b97487 100644 --- a/snapshots/web/plan-review/approved-expanded.expected.md +++ b/snapshots/web/plan-review/approved-expanded.expected.md @@ -60,7 +60,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": @@ -71,4 +71,9 @@ - img - button "4% 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 10.2K tok · Output 346 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "10.6K tok · Cache hit 51%": + - img + - text: 10.6K tokCache hit 51% diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md index 9b2fea933a..c04fb13bad 100644 --- a/snapshots/web/plan-review/approved.expected.md +++ b/snapshots/web/plan-review/approved.expected.md @@ -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 "4% 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 10.2K tok · Output 346 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "10.6K tok · Cache hit 51%": + - img + - text: 10.6K tokCache hit 51% diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md index f07f6b2837..94727a92fe 100644 --- a/snapshots/web/ptc-round/ui.expected.md +++ b/snapshots/web/ptc-round/ui.expected.md @@ -61,7 +61,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": @@ -72,4 +72,9 @@ - img - button "7% 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 52% Input 17.2K tok · Output 252 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "17.5K tok · Cache hit 52%": + - img + - text: 17.5K tokCache hit 52% diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md index aba4bedb58..a57598a5c3 100644 --- a/snapshots/web/question-composer/answered-expanded.expected.md +++ b/snapshots/web/question-composer/answered-expanded.expected.md @@ -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 "3% 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 95% Input 8.6K tok · Output 180 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "8.8K tok · Cache hit 95%": + - img + - text: 8.8K tokCache hit 95% diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md index a2f7298f6b..7d7da25ef6 100644 --- a/snapshots/web/question-composer/answered.expected.md +++ b/snapshots/web/question-composer/answered.expected.md @@ -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 "3% 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 95% Input 8.6K tok · Output 180 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "8.8K tok · Cache hit 95%": + - img + - text: 8.8K tokCache hit 95% diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md index 4b48983c38..d23da91aed 100644 --- a/snapshots/web/question-composer/cancelled.expected.md +++ b/snapshots/web/question-composer/cancelled.expected.md @@ -31,7 +31,7 @@ - list: - listitem: Which color do you prefer? - button "Inspect" -- 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,4 +42,9 @@ - img - button "3% of context used" - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 97% Input 4.2K tok · Output 158 tok +- button "1 turns 1 steps · {{throughput}} tok/s": + - img + - text: 1 turns 1 steps{{throughput}} tok/s +- button "4.4K tok · Cache hit 97%": + - img + - text: 4.4K tokCache hit 97% diff --git a/snapshots/web/queue-actions/failed.expected.md b/snapshots/web/queue-actions/failed.expected.md index d0f83b7fa3..65482b2a8d 100644 --- a/snapshots/web/queue-actions/failed.expected.md +++ b/snapshots/web/queue-actions/failed.expected.md @@ -1,5 +1,5 @@ - alert: Queue submission failed (session/agent-busy) -- textbox "Message or run a task... / commands, @ files or sessions": +- textbox "Message or run a task, / commands, @ files or sessions": - paragraph: Queue submission to retry - button "Commands": - img diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md index 85489c97ef..6e9d8c1134 100644 --- a/snapshots/web/queue-actions/preserved-expanded.expected.md +++ b/snapshots/web/queue-actions/preserved-expanded.expected.md @@ -58,7 +58,7 @@ - img - button "Steer queued message" [disabled]: - 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": @@ -68,4 +68,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 diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md index a4330098cc..a4547ed17d 100644 --- a/snapshots/web/queue-actions/preserved.expected.md +++ b/snapshots/web/queue-actions/preserved.expected.md @@ -54,7 +54,7 @@ - img - button "Steer queued message" [disabled]: - 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": @@ -64,4 +64,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 diff --git a/snapshots/web/queue-actions/sending.expected.md b/snapshots/web/queue-actions/sending.expected.md index c31dbd674a..d2b2a0d9de 100644 --- a/snapshots/web/queue-actions/sending.expected.md +++ b/snapshots/web/queue-actions/sending.expected.md @@ -34,7 +34,7 @@ - img - button "Steer queued message" [disabled]: - 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": diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md index 7fdc60ffaa..7ea64dd0f6 100644 --- a/snapshots/web/queued-image/delivered.expected.md +++ b/snapshots/web/queued-image/delivered.expected.md @@ -78,7 +78,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": @@ -89,4 +89,9 @@ - img - button "6% of context used" - button "Send message" [disabled] -- text: 3 turns · 3 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok +- button "3 turns 3 steps · {{throughput}} tok/s": + - img + - text: 3 turns 3 steps{{throughput}} tok/s +- button "15.7K tok · Cache hit 99%": + - img + - text: 15.7K tokCache hit 99% diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md index 67adda5e9e..527ad8658a 100644 --- a/snapshots/web/seeded-history/command-row.expected.md +++ b/snapshots/web/seeded-history/command-row.expected.md @@ -60,7 +60,7 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- 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": @@ -70,4 +70,9 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "16K tok · Cache hit 98%": + - img + - text: 16K tokCache hit 98% diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md index d2ce9690cb..ba2ac9fd6b 100644 --- a/snapshots/web/seeded-history/feedback-row.expected.md +++ b/snapshots/web/seeded-history/feedback-row.expected.md @@ -64,7 +64,7 @@ - img - text: "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}." - text: "Feedback recorded for session {{seededId}} 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": @@ -74,4 +74,9 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "16K tok · Cache hit 98%": + - img + - text: 16K tokCache hit 98% diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md index 84c770538d..b655c75182 100644 --- a/snapshots/web/seeded-history/ui-expanded.expected.md +++ b/snapshots/web/seeded-history/ui-expanded.expected.md @@ -58,7 +58,7 @@ - img - img - text: Context injection AGENTS.md -- 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": @@ -68,4 +68,9 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "16K tok · Cache hit 98%": + - img + - text: 16K tokCache hit 98% diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md index ed9567ae05..90642e4c58 100644 --- a/snapshots/web/seeded-history/ui.expected.md +++ b/snapshots/web/seeded-history/ui.expected.md @@ -40,7 +40,7 @@ - img - img - text: Context injection AGENTS.md -- 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,4 +50,9 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 98% Input 15.8K tok · Output 135 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "16K tok · Cache hit 98%": + - img + - text: 16K tokCache hit 98% diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md index 8f739e02c1..737d74f2a6 100644 --- a/snapshots/web/skill-tool-row/ui.expected.md +++ b/snapshots/web/skill-tool-row/ui.expected.md @@ -53,7 +53,7 @@ - img - text: Ran for {{duration}} - text: {{date}} {{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 @@ - text: DeepSeek-V4-Flash - img - button "Send message" [disabled] -- text: 1 turns · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 280 tok · Output 30 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "310 tok · Cache hit 0%": + - img + - text: 310 tokCache hit 0% diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md index dbf54a6910..fc4fa021e5 100644 --- a/snapshots/web/steering/settled-expanded.expected.md +++ b/snapshots/web/steering/settled-expanded.expected.md @@ -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": @@ -56,4 +56,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 10 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "30 tok · Cache hit 0%": + - img + - text: 30 tokCache hit 0% diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md index d84e7d053c..0ff456ca8a 100644 --- a/snapshots/web/steering/settled.expected.md +++ b/snapshots/web/steering/settled.expected.md @@ -37,7 +37,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": @@ -48,4 +48,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 10 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "30 tok · Cache hit 0%": + - img + - text: 30 tokCache hit 0% diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md index a31c527d2a..92e5e2c31a 100644 --- a/snapshots/web/subagent-conversation/ui-expanded.expected.md +++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md @@ -72,7 +72,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" [disabled]: @@ -80,4 +80,9 @@ - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] -- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok +- button "2 turns 2 steps · {{throughput}} tok/s": + - img + - text: 2 turns 2 steps{{throughput}} tok/s +- button "15.7K tok · Cache hit 99%": + - img + - text: 15.7K tokCache hit 99% diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md index 887f0a722b..ec7b77fe58 100644 --- a/snapshots/web/subagent-conversation/ui.expected.md +++ b/snapshots/web/subagent-conversation/ui.expected.md @@ -60,7 +60,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" [disabled]: @@ -68,4 +68,9 @@ - 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] -- text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok +- button "2 turns 2 steps · {{throughput}} tok/s": + - img + - text: 2 turns 2 steps{{throughput}} tok/s +- button "15.7K tok · Cache hit 99%": + - img + - text: 15.7K tokCache hit 99% diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md index 4ef04fc9a1..4f9b0b72f3 100644 --- a/snapshots/web/turn-tail-actions/completed.expected.md +++ b/snapshots/web/turn-tail-actions/completed.expected.md @@ -37,7 +37,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": @@ -48,4 +48,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 50% Input 15.7K tok · Output 112 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "15.8K tok · Cache hit 50%": + - img + - text: 15.8K tokCache hit 50% diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md index fc39e90660..2108bafd30 100644 --- a/snapshots/web/turn-tail-actions/focused.expected.md +++ b/snapshots/web/turn-tail-actions/focused.expected.md @@ -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 50% Input 15.7K tok · Output 112 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "15.8K tok · Cache hit 50%": + - img + - text: 15.8K tokCache hit 50% diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md index 5808186525..d4fced7fe3 100644 --- a/snapshots/web/turn-tail-actions/running.expected.md +++ b/snapshots/web/turn-tail-actions/running.expected.md @@ -34,7 +34,7 @@ - text: Bash Print alpha to stdout - 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": @@ -45,4 +45,9 @@ - img - button "6% of context used" - button "Stop generating" -- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 7.8K tok · Output 109 tok +- button "1 turns 1 steps · {{throughput}} tok/s": + - img + - text: 1 turns 1 steps{{throughput}} tok/s +- button "7.9K tok · Cache hit 0%": + - img + - text: 7.9K tokCache hit 0% diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md index 2552b536b2..fd6de1de07 100644 --- a/snapshots/web/turn-tail-actions/settled.expected.md +++ b/snapshots/web/turn-tail-actions/settled.expected.md @@ -36,7 +36,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": @@ -47,4 +47,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 0% Input 7.8K tok · Output 109 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "7.9K tok · Cache hit 0%": + - img + - text: 7.9K tokCache hit 0% diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md index 92686f6691..916569dc9c 100644 --- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md +++ b/snapshots/web/turn-tail-actions/usage-expanded.expected.md @@ -37,7 +37,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": @@ -48,4 +48,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 50% Input 15.7K tok · Output 112 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "15.8K tok · Cache hit 50%": + - img + - text: 15.8K tokCache hit 50% diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md index 8dc1f0698c..1aa9b33fc1 100644 --- a/snapshots/web/web-search-round/ui.expected.md +++ b/snapshots/web/web-search-round/ui.expected.md @@ -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 · 2 steps LLM {{duration}} · Tool call {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 0% Input 22 tok · Output 7 tok +- button "1 turns 2 steps · {{throughput}} tok/s": + - img + - text: 1 turns 2 steps{{throughput}} tok/s +- button "29 tok · Cache hit 0%": + - img + - text: 29 tokCache hit 0%