+
{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..1749d469d9 100644
--- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
+++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css
@@ -34,12 +34,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 +123,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 +153,12 @@
}
/* 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;
+ padding: 2px 8px 0 14px;
font-family: var(--dsw-font-family);
font-size: inherit;
line-height: inherit;
@@ -185,10 +186,10 @@
}
/* 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). */
.placeholder {
position: absolute;
- inset: 4px 8px auto 16px;
+ inset: 2px 8px auto 14px;
color: var(--dsw-alias-label-caption);
pointer-events: none;
user-select: none;
@@ -238,10 +239,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 {
From a49376a2ca4082c19a1c5670e1ed1d803e196bee Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Fri, 4 Sep 2026 20:21:46 +0800
Subject: [PATCH 03/10] fix(client): reword composer placeholders around the /
and @ triggers
The default and hero placeholders drop the ellipsis and name the / commands
and @ references as comma-separated actions; goldens and e2e assertions
refresh with the copy.
---
.../tests/command-image-envelope.expected.e2e.ts | 2 +-
.../conversation-expanded.expected.md | 2 +-
.../github-ready-review/conversation.expected.md | 2 +-
.../goal-command-presentation/ui.expected.md | 2 +-
.../expected/markdown-cjk-strong/ui.expected.md | 2 +-
.../tests/expected/markdown-images/ui.expected.md | 2 +-
.../markdown-inline-code-links/ui.expected.md | 2 +-
.../tests/expected/math-rendering/ui.expected.md | 2 +-
.../expected/reference-composer/order.expected.md | 2 +-
.../skill-user-invoke/ui-expanded.expected.md | 2 +-
.../expected/skill-user-invoke/ui.expected.md | 2 +-
.../expected/stats-paged-history/ui.expected.md | 2 +-
.../tests/expected/steer-all/mid-steer.expected.md | 2 +-
.../steer-all/settled-expanded.expected.md | 2 +-
.../tests/expected/steer-all/settled.expected.md | 2 +-
apps/web/tests/goal-bar.e2e.ts | 2 +-
apps/web/tests/image-display.expected.e2e.ts | 6 +++---
apps/web/tests/preview-boot.e2e.ts | 2 +-
.../mid-stream.expected.md | 2 +-
apps/web/tests/startup-auto-selection.e2e.ts | 2 +-
apps/web/tests/subagent-conversation.e2e.ts | 6 +++---
apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +-
apps/web/tests/support.ts | 4 ++--
.../client/ui-conversation/src/client/locales.ts | 8 ++++----
.../tests/input-bar.client.spec.tsx | 14 +++++++-------
snapshots/web/bash-abort-row/ui.expected.md | 2 +-
snapshots/web/cordis-tool-round/ui.expected.md | 2 +-
.../web/feedback-command/ack-expanded.expected.md | 2 +-
snapshots/web/feedback-command/ack.expected.md | 2 +-
.../web/feedback-release/ack-expanded.expected.md | 2 +-
snapshots/web/feedback-release/ack.expected.md | 2 +-
.../web/fresh-round-trip/ui-expanded.expected.md | 2 +-
snapshots/web/fresh-round-trip/ui.expected.md | 2 +-
.../ui-expanded.expected.md | 2 +-
.../web/goal-multi-turn-actions/ui.expected.md | 2 +-
snapshots/web/lifecycle-chrome/hero.expected.md | 2 +-
.../web/lifecycle-chrome/plan-active.expected.md | 2 +-
.../lifecycle-chrome/reloaded-expanded.expected.md | 2 +-
.../web/lifecycle-chrome/reloaded.expected.md | 2 +-
.../live-interactions/cancel-expanded.expected.md | 2 +-
snapshots/web/live-interactions/cancel.expected.md | 2 +-
.../web/live-interactions/error-auth.expected.md | 2 +-
.../web/live-interactions/loading.expected.md | 2 +-
.../live-interactions/retry-exhausted.expected.md | 2 +-
.../live-interactions/retry-expanded.expected.md | 2 +-
snapshots/web/live-interactions/retry.expected.md | 2 +-
.../live-interactions/running-draft.expected.md | 2 +-
snapshots/web/message-actions/ui.expected.md | 2 +-
snapshots/web/minimal-preset/ui.expected.md | 2 +-
.../web/plan-review/approved-expanded.expected.md | 2 +-
snapshots/web/plan-review/approved.expected.md | 2 +-
snapshots/web/ptc-round/ui.expected.md | 2 +-
.../answered-expanded.expected.md | 2 +-
.../web/question-composer/answered.expected.md | 2 +-
.../web/question-composer/cancelled.expected.md | 2 +-
.../queue-actions/preserved-expanded.expected.md | 2 +-
snapshots/web/queue-actions/preserved.expected.md | 2 +-
snapshots/web/queued-image/delivered.expected.md | 2 +-
.../web/seeded-history/command-row.expected.md | 2 +-
.../web/seeded-history/feedback-row.expected.md | 2 +-
.../web/seeded-history/ui-expanded.expected.md | 2 +-
snapshots/web/seeded-history/ui.expected.md | 2 +-
snapshots/web/skill-tool-row/ui.expected.md | 2 +-
.../web/steering/settled-expanded.expected.md | 2 +-
snapshots/web/steering/settled.expected.md | 2 +-
.../subagent-conversation/ui-expanded.expected.md | 2 +-
snapshots/web/subagent-conversation/ui.expected.md | 2 +-
.../web/turn-tail-actions/completed.expected.md | 2 +-
.../web/turn-tail-actions/focused.expected.md | 2 +-
.../web/turn-tail-actions/running.expected.md | 2 +-
.../web/turn-tail-actions/settled.expected.md | 2 +-
.../turn-tail-actions/usage-expanded.expected.md | 2 +-
snapshots/web/web-search-round/ui.expected.md | 2 +-
73 files changed, 87 insertions(+), 87 deletions(-)
diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts
index 47fb91069b..48d5f8f407 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, run / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
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 3a29a14b51..9f350d3f40 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
@@ -48,7 +48,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
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 0a5fe67b90..b57f39a3c4 100644
--- a/apps/web/tests/expected/github-ready-review/conversation.expected.md
+++ b/apps/web/tests/expected/github-ready-review/conversation.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
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 c10eef6aac..7567b6422f 100644
--- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md
+++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md
@@ -14,7 +14,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 2fba524c5d..0cf58cabfa 100644
--- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
+++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
@@ -48,7 +48,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md
index 3843143d1f..c4fa04a381 100644
--- a/apps/web/tests/expected/markdown-images/ui.expected.md
+++ b/apps/web/tests/expected/markdown-images/ui.expected.md
@@ -27,7 +27,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 2d2127cbb2..63013648ea 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
@@ -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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/expected/math-rendering/ui.expected.md b/apps/web/tests/expected/math-rendering/ui.expected.md
index fba05ffe71..bf0ed86acf 100644
--- a/apps/web/tests/expected/math-rendering/ui.expected.md
+++ b/apps/web/tests/expected/math-rendering/ui.expected.md
@@ -43,7 +43,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/expected/reference-composer/order.expected.md b/apps/web/tests/expected/reference-composer/order.expected.md
index 7cec2eaf08..6b17de40cb 100644
--- a/apps/web/tests/expected/reference-composer/order.expected.md
+++ b/apps/web/tests/expected/reference-composer/order.expected.md
@@ -13,7 +13,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 07be7102ff..6d615be5d1 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
@@ -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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 7ddf071159..6285f1f4a9 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
@@ -32,7 +32,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 fc8039e282..b0aeaeb11c 100644
--- a/apps/web/tests/expected/stats-paged-history/ui.expected.md
+++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md
@@ -459,7 +459,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 c3847df394..fd3d079115 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -31,7 +31,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 73e987dfa2..326c57de80 100644
--- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md
+++ b/apps/web/tests/expected/steer-all/settled-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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/expected/steer-all/settled.expected.md b/apps/web/tests/expected/steer-all/settled.expected.md
index cd48c29075..88f6522fd1 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -38,7 +38,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts
index f9cd46b60c..5b077016e5 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, run / 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 3b5f8d00f2..c2a5e129f0 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, run / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -165,7 +165,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, run / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
@@ -220,7 +220,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, run / commands, @ files or sessions"]',
)
if (surface === null) throw new Error('composer surface missing')
return surface
diff --git a/apps/web/tests/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts
index 656285cbf9..7b5f49c1c0 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, run / 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 cd696c5411..f7ad0b36b5 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
@@ -24,7 +24,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/apps/web/tests/startup-auto-selection.e2e.ts b/apps/web/tests/startup-auto-selection.e2e.ts
index 1404402b4f..7e7065e6b2 100644
--- a/apps/web/tests/startup-auto-selection.e2e.ts
+++ b/apps/web/tests/startup-auto-selection.e2e.ts
@@ -130,7 +130,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, run / commands, @ files or sessions"]')
.waitFor({ timeout: 15_000 })
acknowledgeReloadConnectionLoss(tripwire, warningsBefore)
diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts
index d9b66749de..af7407778a 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, run / 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, run / 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, run / 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/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts
index 5cf3efa820..bf770c3874 100644
--- a/apps/web/tests/subagent-interrupt-ui.e2e.ts
+++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts
@@ -263,7 +263,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
.getByRole('button').first().click()
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
- 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, run / commands, @ files or sessions' })
await input.waitFor({ timeout: 15_000 })
expect(await input.isDisabled()).toBe(false)
diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts
index 63cf8c3ff8..252ef70ee7 100644
--- a/apps/web/tests/support.ts
+++ b/apps/web/tests/support.ts
@@ -112,7 +112,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, run / commands, @ files or sessions"]')
.waitFor({ timeout: 15_000 })
}
@@ -135,7 +135,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-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 3faaf8f009..0fa83f27eb 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': '指令',
@@ -164,10 +164,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, run / 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, run / commands, @ files or sessions',
'placeholder.workspace': 'Choose a workspace to start',
'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages',
'input.commands': 'Commands',
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 0edc8b9ef6..6b9f4e0f81 100644
--- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx
@@ -459,9 +459,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')],
@@ -469,7 +469,7 @@ describe('Enter semantics', () => {
address: { parentSessionId: 'parent' as SessionId, childSessionId: SID, mode: 'continuable' },
parentAvailable: true,
},
- }).placeholder).toBe('发消息或做任务… / 调用指令 @ 文件或对话')
+ }).placeholder).toBe('发消息或创建任务, / 调用指令, @ 文件或对话')
expect(bench({
running: true,
queue: [row('q-1')],
@@ -481,7 +481,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({
@@ -977,7 +977,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')
})
@@ -1024,7 +1024,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/snapshots/web/bash-abort-row/ui.expected.md b/snapshots/web/bash-abort-row/ui.expected.md
index e633f1ca1c..ed88971715 100644
--- a/snapshots/web/bash-abort-row/ui.expected.md
+++ b/snapshots/web/bash-abort-row/ui.expected.md
@@ -26,7 +26,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
diff --git a/snapshots/web/cordis-tool-round/ui.expected.md b/snapshots/web/cordis-tool-round/ui.expected.md
index 78605cc160..264eebfaa3 100644
--- a/snapshots/web/cordis-tool-round/ui.expected.md
+++ b/snapshots/web/cordis-tool-round/ui.expected.md
@@ -114,7 +114,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/feedback-command/ack-expanded.expected.md b/snapshots/web/feedback-command/ack-expanded.expected.md
index 5b20a1e993..01883c3735 100644
--- a/snapshots/web/feedback-command/ack-expanded.expected.md
+++ b/snapshots/web/feedback-command/ack-expanded.expected.md
@@ -44,7 +44,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/feedback-command/ack.expected.md b/snapshots/web/feedback-command/ack.expected.md
index 89f5b6f42d..efb2bc2188 100644
--- a/snapshots/web/feedback-command/ack.expected.md
+++ b/snapshots/web/feedback-command/ack.expected.md
@@ -36,7 +36,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is enabled."
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/feedback-release/ack-expanded.expected.md b/snapshots/web/feedback-release/ack-expanded.expected.md
index 5b753960a5..4ff4b16da8 100644
--- a/snapshots/web/feedback-release/ack-expanded.expected.md
+++ b/snapshots/web/feedback-release/ack-expanded.expected.md
@@ -44,7 +44,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/feedback-release/ack.expected.md b/snapshots/web/feedback-release/ack.expected.md
index 057d13cd9c..139650c4b7 100644
--- a/snapshots/web/feedback-release/ack.expected.md
+++ b/snapshots/web/feedback-release/ack.expected.md
@@ -36,7 +36,7 @@
- img
- img
- text: "feedback Feedback recorded for session session-{{uuid}} Anonymous user: {{uuid}}. Session sharing is feedback-gated; recording feedback uploads the session records not yet shared."
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/fresh-round-trip/ui-expanded.expected.md b/snapshots/web/fresh-round-trip/ui-expanded.expected.md
index 108ee4050c..efdf765b06 100644
--- a/snapshots/web/fresh-round-trip/ui-expanded.expected.md
+++ b/snapshots/web/fresh-round-trip/ui-expanded.expected.md
@@ -48,7 +48,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/fresh-round-trip/ui.expected.md b/snapshots/web/fresh-round-trip/ui.expected.md
index 9a83925d23..155f94151c 100644
--- a/snapshots/web/fresh-round-trip/ui.expected.md
+++ b/snapshots/web/fresh-round-trip/ui.expected.md
@@ -32,7 +32,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
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 700f499256..8f434cbd5b 100644
--- a/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
+++ b/snapshots/web/goal-multi-turn-actions/ui-expanded.expected.md
@@ -226,7 +226,7 @@
- text: {{clock}}
- button "Back to bottom":
- img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/goal-multi-turn-actions/ui.expected.md b/snapshots/web/goal-multi-turn-actions/ui.expected.md
index fd874dc327..6458d97ec7 100644
--- a/snapshots/web/goal-multi-turn-actions/ui.expected.md
+++ b/snapshots/web/goal-multi-turn-actions/ui.expected.md
@@ -114,7 +114,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/lifecycle-chrome/hero.expected.md b/snapshots/web/lifecycle-chrome/hero.expected.md
index 2d9f315f43..8008769d64 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, run / 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 5f53d73cfd..7eb9ecc43d 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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md b/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
index 00d271cff3..d5e2baa73d 100644
--- a/snapshots/web/lifecycle-chrome/reloaded-expanded.expected.md
+++ b/snapshots/web/lifecycle-chrome/reloaded-expanded.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/lifecycle-chrome/reloaded.expected.md b/snapshots/web/lifecycle-chrome/reloaded.expected.md
index bc5558ab03..02668c7ba4 100644
--- a/snapshots/web/lifecycle-chrome/reloaded.expected.md
+++ b/snapshots/web/lifecycle-chrome/reloaded.expected.md
@@ -32,7 +32,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/cancel-expanded.expected.md b/snapshots/web/live-interactions/cancel-expanded.expected.md
index b61cd9e51a..9e53fe0ffe 100644
--- a/snapshots/web/live-interactions/cancel-expanded.expected.md
+++ b/snapshots/web/live-interactions/cancel-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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/cancel.expected.md b/snapshots/web/live-interactions/cancel.expected.md
index 4dbfb09b4a..300d0545a5 100644
--- a/snapshots/web/live-interactions/cancel.expected.md
+++ b/snapshots/web/live-interactions/cancel.expected.md
@@ -33,7 +33,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/error-auth.expected.md b/snapshots/web/live-interactions/error-auth.expected.md
index ae854c557c..b4f2894bbd 100644
--- a/snapshots/web/live-interactions/error-auth.expected.md
+++ b/snapshots/web/live-interactions/error-auth.expected.md
@@ -23,7 +23,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/loading.expected.md b/snapshots/web/live-interactions/loading.expected.md
index 4307a62de2..ec8e57263a 100644
--- a/snapshots/web/live-interactions/loading.expected.md
+++ b/snapshots/web/live-interactions/loading.expected.md
@@ -22,7 +22,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/retry-exhausted.expected.md b/snapshots/web/live-interactions/retry-exhausted.expected.md
index 7dc6c9f613..74f3dcd3b3 100644
--- a/snapshots/web/live-interactions/retry-exhausted.expected.md
+++ b/snapshots/web/live-interactions/retry-exhausted.expected.md
@@ -25,7 +25,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/retry-expanded.expected.md b/snapshots/web/live-interactions/retry-expanded.expected.md
index 1c6a87f6d1..16d9761a90 100644
--- a/snapshots/web/live-interactions/retry-expanded.expected.md
+++ b/snapshots/web/live-interactions/retry-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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/retry.expected.md b/snapshots/web/live-interactions/retry.expected.md
index 93107fe00b..e729aa627f 100644
--- a/snapshots/web/live-interactions/retry.expected.md
+++ b/snapshots/web/live-interactions/retry.expected.md
@@ -32,7 +32,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/live-interactions/running-draft.expected.md b/snapshots/web/live-interactions/running-draft.expected.md
index 6f8f2cd629..909214d76c 100644
--- a/snapshots/web/live-interactions/running-draft.expected.md
+++ b/snapshots/web/live-interactions/running-draft.expected.md
@@ -22,7 +22,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, run / 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 b8ea35966b..20a6119243 100644
--- a/snapshots/web/message-actions/ui.expected.md
+++ b/snapshots/web/message-actions/ui.expected.md
@@ -66,7 +66,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/minimal-preset/ui.expected.md b/snapshots/web/minimal-preset/ui.expected.md
index e26d5cdb11..90e013e31f 100644
--- a/snapshots/web/minimal-preset/ui.expected.md
+++ b/snapshots/web/minimal-preset/ui.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/plan-review/approved-expanded.expected.md b/snapshots/web/plan-review/approved-expanded.expected.md
index 0233cfa370..73ee4e10ae 100644
--- a/snapshots/web/plan-review/approved-expanded.expected.md
+++ b/snapshots/web/plan-review/approved-expanded.expected.md
@@ -58,7 +58,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/plan-review/approved.expected.md b/snapshots/web/plan-review/approved.expected.md
index 14ba8a416e..981b606fb1 100644
--- a/snapshots/web/plan-review/approved.expected.md
+++ b/snapshots/web/plan-review/approved.expected.md
@@ -38,7 +38,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/ptc-round/ui.expected.md b/snapshots/web/ptc-round/ui.expected.md
index 80b8866505..6cbb8be04b 100644
--- a/snapshots/web/ptc-round/ui.expected.md
+++ b/snapshots/web/ptc-round/ui.expected.md
@@ -53,7 +53,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/question-composer/answered-expanded.expected.md b/snapshots/web/question-composer/answered-expanded.expected.md
index b2f7ba6294..90e2fd340e 100644
--- a/snapshots/web/question-composer/answered-expanded.expected.md
+++ b/snapshots/web/question-composer/answered-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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/question-composer/answered.expected.md b/snapshots/web/question-composer/answered.expected.md
index a6dc5b28fd..5c01a40d89 100644
--- a/snapshots/web/question-composer/answered.expected.md
+++ b/snapshots/web/question-composer/answered.expected.md
@@ -32,7 +32,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/question-composer/cancelled.expected.md b/snapshots/web/question-composer/cancelled.expected.md
index 0c18353ee1..147ebabd2f 100644
--- a/snapshots/web/question-composer/cancelled.expected.md
+++ b/snapshots/web/question-composer/cancelled.expected.md
@@ -29,7 +29,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/queue-actions/preserved-expanded.expected.md b/snapshots/web/queue-actions/preserved-expanded.expected.md
index 5a5143fc2c..58159d8fa8 100644
--- a/snapshots/web/queue-actions/preserved-expanded.expected.md
+++ b/snapshots/web/queue-actions/preserved-expanded.expected.md
@@ -56,7 +56,7 @@
- img
- button "Steer queued message" [disabled]:
- img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/queue-actions/preserved.expected.md b/snapshots/web/queue-actions/preserved.expected.md
index 9798e5da4f..acccb8ba21 100644
--- a/snapshots/web/queue-actions/preserved.expected.md
+++ b/snapshots/web/queue-actions/preserved.expected.md
@@ -52,7 +52,7 @@
- img
- button "Steer queued message" [disabled]:
- img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/queued-image/delivered.expected.md b/snapshots/web/queued-image/delivered.expected.md
index b5dc457a4e..4abc5d808d 100644
--- a/snapshots/web/queued-image/delivered.expected.md
+++ b/snapshots/web/queued-image/delivered.expected.md
@@ -76,7 +76,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/seeded-history/command-row.expected.md b/snapshots/web/seeded-history/command-row.expected.md
index 3f4ed25eb3..7f52c24bbc 100644
--- a/snapshots/web/seeded-history/command-row.expected.md
+++ b/snapshots/web/seeded-history/command-row.expected.md
@@ -58,7 +58,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
diff --git a/snapshots/web/seeded-history/feedback-row.expected.md b/snapshots/web/seeded-history/feedback-row.expected.md
index 1265a3e50e..1724e6fa96 100644
--- a/snapshots/web/seeded-history/feedback-row.expected.md
+++ b/snapshots/web/seeded-history/feedback-row.expected.md
@@ -62,7 +62,7 @@
- img
- text: "feedback Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}. Session sharing is not configured."
- text: "Feedback recorded for session {{seededId}} Anonymous user: {{uuid}}. Session sharing is not configured."
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Read Only"': Read Only
diff --git a/snapshots/web/seeded-history/ui-expanded.expected.md b/snapshots/web/seeded-history/ui-expanded.expected.md
index 2d18d8ec62..2f67dbd50d 100644
--- a/snapshots/web/seeded-history/ui-expanded.expected.md
+++ b/snapshots/web/seeded-history/ui-expanded.expected.md
@@ -56,7 +56,7 @@
- img
- img
- text: Context injection AGENTS.md
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/seeded-history/ui.expected.md b/snapshots/web/seeded-history/ui.expected.md
index 4711ff7304..6778257a55 100644
--- a/snapshots/web/seeded-history/ui.expected.md
+++ b/snapshots/web/seeded-history/ui.expected.md
@@ -38,7 +38,7 @@
- img
- img
- text: Context injection AGENTS.md
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/skill-tool-row/ui.expected.md b/snapshots/web/skill-tool-row/ui.expected.md
index cff07a3aae..48b96721cc 100644
--- a/snapshots/web/skill-tool-row/ui.expected.md
+++ b/snapshots/web/skill-tool-row/ui.expected.md
@@ -51,7 +51,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Full access"': Full access
diff --git a/snapshots/web/steering/settled-expanded.expected.md b/snapshots/web/steering/settled-expanded.expected.md
index bfd48e7ed9..16c7f00fd7 100644
--- a/snapshots/web/steering/settled-expanded.expected.md
+++ b/snapshots/web/steering/settled-expanded.expected.md
@@ -43,7 +43,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/steering/settled.expected.md b/snapshots/web/steering/settled.expected.md
index 0d190d89e9..e62b8ab4d4 100644
--- a/snapshots/web/steering/settled.expected.md
+++ b/snapshots/web/steering/settled.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/subagent-conversation/ui-expanded.expected.md b/snapshots/web/subagent-conversation/ui-expanded.expected.md
index 2cbe5b1fa8..2a0549a1d7 100644
--- a/snapshots/web/subagent-conversation/ui-expanded.expected.md
+++ b/snapshots/web/subagent-conversation/ui-expanded.expected.md
@@ -75,7 +75,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Custom"': Custom
diff --git a/snapshots/web/subagent-conversation/ui.expected.md b/snapshots/web/subagent-conversation/ui.expected.md
index 38b587e257..17e68fb4ed 100644
--- a/snapshots/web/subagent-conversation/ui.expected.md
+++ b/snapshots/web/subagent-conversation/ui.expected.md
@@ -63,7 +63,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Custom"': Custom
diff --git a/snapshots/web/turn-tail-actions/completed.expected.md b/snapshots/web/turn-tail-actions/completed.expected.md
index 637055a274..95f6f440f5 100644
--- a/snapshots/web/turn-tail-actions/completed.expected.md
+++ b/snapshots/web/turn-tail-actions/completed.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/turn-tail-actions/focused.expected.md b/snapshots/web/turn-tail-actions/focused.expected.md
index 94870d19c8..2ddeaa1c4d 100644
--- a/snapshots/web/turn-tail-actions/focused.expected.md
+++ b/snapshots/web/turn-tail-actions/focused.expected.md
@@ -48,7 +48,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/turn-tail-actions/running.expected.md b/snapshots/web/turn-tail-actions/running.expected.md
index d79602b47c..90a45f8028 100644
--- a/snapshots/web/turn-tail-actions/running.expected.md
+++ b/snapshots/web/turn-tail-actions/running.expected.md
@@ -32,7 +32,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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/turn-tail-actions/settled.expected.md b/snapshots/web/turn-tail-actions/settled.expected.md
index 26bd7a6d83..bfed5ecb1a 100644
--- a/snapshots/web/turn-tail-actions/settled.expected.md
+++ b/snapshots/web/turn-tail-actions/settled.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/turn-tail-actions/usage-expanded.expected.md b/snapshots/web/turn-tail-actions/usage-expanded.expected.md
index 43d984fc53..4c8f84e2bd 100644
--- a/snapshots/web/turn-tail-actions/usage-expanded.expected.md
+++ b/snapshots/web/turn-tail-actions/usage-expanded.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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
diff --git a/snapshots/web/web-search-round/ui.expected.md b/snapshots/web/web-search-round/ui.expected.md
index 0968ee2b75..8b2f000f9a 100644
--- a/snapshots/web/web-search-round/ui.expected.md
+++ b/snapshots/web/web-search-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, run / commands, @ files or sessions"
- button "Commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
From eac164392d1b35de75107826dd610c3eeac6da4d Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Fri, 4 Sep 2026 20:22:11 +0800
Subject: [PATCH 04/10] fix(client): name the context meter's tools row
precisely and drop the header approximation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The heuristic tools segment counts tool schemas only, so the row reads 工具定义
/ Tool definitions; the header figures lose the ~ because they are anchored to
provider-reported usage, unlike the heuristic rows that keep it.
---
packages/client/ui-conversation/src/client/locales.ts | 4 ++--
.../src/client/skeleton/ContextMeter.tsx | 4 +++-
.../tests/context-meter.client.spec.tsx | 10 +++++-----
3 files changed, 10 insertions(+), 8 deletions(-)
diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts
index 0fa83f27eb..ed93b09b4b 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -49,7 +49,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 使用另一行为',
@@ -200,7 +200,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..396d72b408 100644
--- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx
+++ b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx
@@ -135,8 +135,10 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
{headBefore}
{reading}
{headAfter}
+ {/* No `~`: the headline figure is anchored to provider-reported
+ usage, unlike the heuristic composition rows below. */}
- {`~${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
+ {`${formatTokens(context.usedTokens, t)} / ${formatTokens(context.contextWindow, t)}`}
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..acf1156ca1 100644
--- a/packages/client/ui-conversation/tests/context-meter.client.spec.tsx
+++ b/packages/client/ui-conversation/tests/context-meter.client.spec.tsx
@@ -54,11 +54,11 @@ describe('ContextMeter', () => {
expect(view.container.querySelector('[role="dialog"]')).toBeNull()
fireEvent.click(trigger)
const panel = view.container.querySelector('[role="dialog"]')!
- expect(panel.textContent).toContain('~32K / 128K')
+ expect(panel.textContent).toContain('32K / 128K')
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)
@@ -94,7 +94,7 @@ describe('ContextMeter', () => {
// `.segment` carries a min-width, so a zero-width part would still paint a
// filled sliver over an empty context.
expect(panel.getElementsByClassName(segmentClass)).toHaveLength(0)
- expect(panel.textContent).toContain('~0 / 128K')
+ expect(panel.textContent).toContain('0 / 128K')
})
it('reads the ring from the projected figure so a compaction shows at once', () => {
@@ -106,14 +106,14 @@ describe('ContextMeter', () => {
})
const trigger = view.getByRole('button', { name: '上下文已用 2%' })
fireEvent.click(trigger)
- expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('~3K / 128K')
+ expect(view.container.querySelector('[role="dialog"]')!.textContent).toContain('3K / 128K')
})
it('omits the composition rows while the contextBreakdown projection is absent', () => {
const view = meter({ contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 } })
fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' }))
const panel = view.container.querySelector('[role="dialog"]')!
- expect(panel.textContent).toContain('~32K / 128K')
+ expect(panel.textContent).toContain('32K / 128K')
expect(panel.textContent).not.toContain('系统提示词')
expect(panel.textContent).not.toContain('对话消息')
// Without composition shares, the bar falls back to one plain segment.
From fd1e51b097ed962b0a634cd37dc497fed9831920 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Fri, 4 Sep 2026 20:25:04 +0800
Subject: [PATCH 05/10] test(web): refresh master-added goldens for the
reworded placeholder
file-upload-round and the clickable-links gallery were recorded on master with
the pre-rewording composer placeholder.
---
apps/web/tests/expected/clickable-links-gallery/ui.expected.md | 2 +-
snapshots/web/file-upload-round/ui.expected.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
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 4babf591db..b0ff0e6411 100644
--- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
+++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
@@ -191,7 +191,7 @@
- text: {{clock}}
- button "Back to bottom":
- img
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
diff --git a/snapshots/web/file-upload-round/ui.expected.md b/snapshots/web/file-upload-round/ui.expected.md
index 2541ec1c4a..7d34ba8037 100644
--- a/snapshots/web/file-upload-round/ui.expected.md
+++ b/snapshots/web/file-upload-round/ui.expected.md
@@ -38,7 +38,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task... / commands, @ files or sessions"
+- textbox "Message or run a task, run / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
From 3997f36999a186e9a24944f162f3fe0882ccd8e4 Mon Sep 17 00:00:00 2001
From: Yif <877193178@qq.com>
Date: Mon, 7 Sep 2026 20:59:19 +0800
Subject: [PATCH 06/10] feat(client): replace the stats strip with two icon
pills and click-open stat dialogs
The A/B winner over the single-line StatsLine: a gauge pill (counts + TPS,
opening the session-stats dialog) and a database pill (billed total +
cache-hit share, opening the exact token-usage dialog), sharing a new
stat-dialog module. StatsLine and its dead locale keys are deleted, the
gauge icon is optically centered, and the composer keeps its 4px clearance
through the data-composer-stats contract. Agent Note added; stale
StatsLine references across implemented notes updated in place.
---
...-token-usage-and-request-context.i18n.yaml | 4 +-
...ojected-token-usage-and-request-context.md | 4 +-
...cted-token-usage-and-request-context.zh.md | 4 +-
...lient-conversation-node-assembly.i18n.yaml | 4 +-
...08-09-client-conversation-node-assembly.md | 4 +-
...09-client-conversation-node-assembly.zh.md | 4 +-
...12-full-session-turn-step-counts.i18n.yaml | 4 +-
...026-08-12-full-session-turn-step-counts.md | 2 +-
...-08-12-full-session-turn-step-counts.zh.md | 2 +-
...versation-adaptive-content-width.i18n.yaml | 4 +-
...-18-conversation-adaptive-content-width.md | 2 +-
...-conversation-adaptive-content-width.zh.md | 2 +-
...08-18-settings-font-size-control.i18n.yaml | 4 +-
.../2026-08-18-settings-font-size-control.md | 2 +-
...026-08-18-settings-font-size-control.zh.md | 2 +-
...-07-composer-session-stats-pills.i18n.yaml | 6 +
...2026-09-07-composer-session-stats-pills.md | 32 ++
...6-09-07-composer-session-stats-pills.zh.md | 32 ++
.../command-image-envelope.expected.e2e.ts | 2 +-
.../clickable-links-gallery/ui.expected.md | 6 +-
.../conversation-expanded.expected.md | 6 +-
.../conversation.expected.md | 6 +-
.../goal-command-presentation/ui.expected.md | 2 +-
.../markdown-cjk-strong/ui.expected.md | 6 +-
.../expected/markdown-images/ui.expected.md | 6 +-
.../markdown-inline-code-links/ui.expected.md | 6 +-
.../expected/math-rendering/ui.expected.md | 6 +-
.../reference-composer/order.expected.md | 2 +-
.../skill-user-invoke/ui-expanded.expected.md | 9 +-
.../expected/skill-user-invoke/ui.expected.md | 9 +-
.../stats-paged-history/ui.expected.md | 6 +-
.../expected/steer-all/mid-steer.expected.md | 2 +-
.../steer-all/settled-expanded.expected.md | 9 +-
.../expected/steer-all/settled.expected.md | 9 +-
apps/web/tests/goal-bar.e2e.ts | 2 +-
apps/web/tests/image-display.expected.e2e.ts | 6 +-
apps/web/tests/lifecycle-chrome.e2e.ts | 3 +-
apps/web/tests/math-rendering.e2e.ts | 2 +-
apps/web/tests/preview-boot.e2e.ts | 2 +-
.../mid-stream.expected.md | 2 +-
apps/web/tests/startup-auto-selection.e2e.ts | 2 +-
apps/web/tests/stats-paged-history.e2e.ts | 2 +-
apps/web/tests/subagent-conversation.e2e.ts | 6 +-
apps/web/tests/subagent-interrupt-ui.e2e.ts | 2 +-
apps/web/tests/support.ts | 2 +-
.../ui-attachment/src/FileCard.module.css | 12 +
.../client/ui-attachment/src/FileCard.tsx | 6 +-
packages/client/ui-chat/src/client/apply.ts | 4 +-
.../src/client/chat/StatsLine.module.css | 26 --
.../ui-chat/src/client/chat/StatsLine.tsx | 209 ------------
.../src/client/chat/StatsPills.module.css | 63 ++++
.../ui-chat/src/client/chat/StatsPills.tsx | 299 ++++++++++++++++++
.../src/client/chat/TurnUsagePanel.module.css | 95 +-----
.../src/client/chat/TurnUsagePanel.tsx | 85 +----
.../src/client/chat/stat-dialog.module.css | 94 ++++++
.../ui-chat/src/client/chat/stat-dialog.ts | 62 ++++
.../ui-chat/src/client/contract/snapshot.ts | 2 +-
.../src/client/contract/turn-metrics.ts | 2 +-
.../chat-snapshot-builder.ts | 4 +-
packages/client/ui-chat/src/client/locale.ts | 26 +-
.../tests/chat-branch-tails.client.spec.tsx | 23 +-
.../ui-chat/tests/chat-stats.client.spec.tsx | 286 +++++++++--------
.../tests/gate-branch-tails.client.spec.tsx | 19 +-
.../ui-conversation/src/client/locales.ts | 4 +-
.../src/client/skeleton/InputBar.module.css | 18 +-
.../src/client/ModelSelect.tsx | 4 +
.../client/ui-primitives/src/icons/index.tsx | 11 +
.../src/client/slot-catalog.ts | 2 +-
snapshots/web/bash-abort-row/ui.expected.md | 9 +-
.../web/cordis-tool-round/ui.expected.md | 9 +-
.../feedback-command/ack-expanded.expected.md | 9 +-
.../web/feedback-command/ack.expected.md | 9 +-
.../feedback-release/ack-expanded.expected.md | 9 +-
.../web/feedback-release/ack.expected.md | 9 +-
.../web/file-upload-round/ui.expected.md | 9 +-
.../fresh-round-trip/ui-expanded.expected.md | 9 +-
snapshots/web/fresh-round-trip/ui.expected.md | 9 +-
.../ui-expanded.expected.md | 9 +-
.../goal-multi-turn-actions/ui.expected.md | 9 +-
.../web/lifecycle-chrome/hero.expected.md | 2 +-
.../lifecycle-chrome/plan-active.expected.md | 2 +-
.../reloaded-expanded.expected.md | 9 +-
.../web/lifecycle-chrome/reloaded.expected.md | 9 +-
.../cancel-expanded.expected.md | 6 +-
.../web/live-interactions/cancel.expected.md | 6 +-
.../live-interactions/error-auth.expected.md | 6 +-
.../web/live-interactions/loading.expected.md | 2 +-
.../retry-exhausted.expected.md | 6 +-
.../retry-expanded.expected.md | 9 +-
.../web/live-interactions/retry.expected.md | 9 +-
.../running-draft.expected.md | 2 +-
snapshots/web/message-actions/ui.expected.md | 9 +-
snapshots/web/minimal-preset/ui.expected.md | 9 +-
.../plan-review/approved-expanded.expected.md | 9 +-
.../web/plan-review/approved.expected.md | 10 +-
snapshots/web/ptc-round/ui.expected.md | 9 +-
.../answered-expanded.expected.md | 9 +-
.../question-composer/answered.expected.md | 9 +-
.../question-composer/cancelled.expected.md | 9 +-
.../preserved-expanded.expected.md | 6 +-
.../web/queue-actions/preserved.expected.md | 6 +-
.../web/queued-image/delivered.expected.md | 9 +-
.../seeded-history/command-row.expected.md | 9 +-
.../seeded-history/feedback-row.expected.md | 9 +-
.../seeded-history/ui-expanded.expected.md | 9 +-
snapshots/web/seeded-history/ui.expected.md | 9 +-
snapshots/web/skill-tool-row/ui.expected.md | 9 +-
.../web/steering/settled-expanded.expected.md | 9 +-
snapshots/web/steering/settled.expected.md | 9 +-
.../ui-expanded.expected.md | 9 +-
.../web/subagent-conversation/ui.expected.md | 9 +-
.../turn-tail-actions/completed.expected.md | 9 +-
.../web/turn-tail-actions/focused.expected.md | 9 +-
.../web/turn-tail-actions/running.expected.md | 9 +-
.../web/turn-tail-actions/settled.expected.md | 9 +-
.../usage-expanded.expected.md | 9 +-
snapshots/web/web-search-round/ui.expected.md | 9 +-
117 files changed, 1256 insertions(+), 737 deletions(-)
create mode 100644 .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.i18n.yaml
create mode 100644 .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
create mode 100644 .agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
delete mode 100644 packages/client/ui-chat/src/client/chat/StatsLine.module.css
delete mode 100644 packages/client/ui-chat/src/client/chat/StatsLine.tsx
create mode 100644 packages/client/ui-chat/src/client/chat/StatsPills.module.css
create mode 100644 packages/client/ui-chat/src/client/chat/StatsPills.tsx
create mode 100644 packages/client/ui-chat/src/client/chat/stat-dialog.module.css
create mode 100644 packages/client/ui-chat/src/client/chat/stat-dialog.ts
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 eee0bed2ef..e919d1145b 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: 50e11b04d6461e99cdddb8e5e2d94bf864228841
-2026-08-09-client-conversation-node-assembly.zh.md: 7be1cd1b7f356caf51b8003821369771b0a6e269
+2026-08-09-client-conversation-node-assembly.md: 8fcd0350db6ea8e30817995781c75f73db562033
+2026-08-09-client-conversation-node-assembly.zh.md: 32af13badb7e38a5a62cfbf80f3c7489decdc0aa
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 50e11b04d6..8fcd0350db 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 7be1cd1b7f..32af13badb 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/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml
index 18ed791f68..cc61d6bc28 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.i18n.yaml
+++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.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/bug-fix/2026-08-12-full-session-turn-step-counts.md
-2026-08-12-full-session-turn-step-counts.md: ecfa00dc3e24101953a9d5a724dba17682d839bd
-2026-08-12-full-session-turn-step-counts.zh.md: 85e2a26b5af51296e20f29af49e909c6e182ea05
+2026-08-12-full-session-turn-step-counts.md: a87fb48bd17992b19c7c5ff89ba5ac4e6df89556
+2026-08-12-full-session-turn-step-counts.zh.md: 21e90a0cbb370e3bc566d265d54c5d64cb6189dd
diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md
index ecfa00dc3e..a87fb48bd1 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.md
@@ -10,7 +10,7 @@ The web chat stats strip folded `StatsLine`'s loaded conversation window (`deriv
## Decision
-A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (beside the `StreamChunk` type it discriminates) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsLine` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline.
+A new function plugin `@deepseek-ai/dsh-session-stats` registers a `sessionStats` projection unit on `ctx.sessionProjections`, mounted as a web-app bundle row. The value carries the strip's whole non-token figure set — `{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`, field names mirroring the window fold so the two swap wholesale. `steps` counts `step/end` events and `turns` counts distinct turns carrying at least one (turn numbers are monotonic, so one `lastTurn` slot suffices); `llmMs` sums `step/start` → `assistant/message`; TTFT records the first non-empty delta chunk per step (surviving in-step `llm/retry`, the window `resetForRetry` parity); decode spans first token → assembled message on usage-reporting steps; `toolMs` pairs `tool/call` → `tool/result` by callId with unresolved calls dropped at `turn/end`. The first-token predicate `isTokenDelta` moved to `@deepseek-ai/dsh-llm/message` (beside the `StreamChunk` type it discriminates) so the host fold and the client timing index share one implementation; client-runtime re-exports it. Delivery is entirely the existing projection seam — history tail-page block, `session/projection` push frames, list rows — with zero changes to apiproxy, wire schemas, or the client runtime. `StatsPills` reads `useProjection('sessionStats')` and falls back to the window fold when the key is undefined (an assembly without the unit). The client connection fixture mirrors the fold as `sessionStatsOf` under its existing every-composed-key discipline.
`step/end` — not `assistant/message` — is the counted event, for two correctness reasons found while reviewing the obvious message-counting design:
diff --git a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md
index 85e2a26b5a..21e90a0cbb 100644
--- a/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md
+++ b/.agents/notes/implemented/bug-fix/2026-08-12-full-session-turn-step-counts.zh.md
@@ -10,7 +10,7 @@ Web 聊天统计条的每个非 token 数字都折算自 `StatsLine` 已加载
## 决定
-新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(与其判别的 `StreamChunk` 类型同处),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsLine` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。
+新的函数插件 `@deepseek-ai/dsh-session-stats` 在 `ctx.sessionProjections` 上注册 `sessionStats` 投影单元,作为 web-app bundle 行挂载。值携带统计条完整的非 token 数字集——`{ turns, steps, llmMs, toolMs, ttftMs, ttftSteps, decodeMs, decodeTokens }`,字段名与窗口折叠一一对应以便整体互换。`steps` 统计 `step/end` 事件,`turns` 统计含至少一条该事件的不同 turn(turn 号单调递增,一个 `lastTurn` 槽即可);`llmMs` 累加 `step/start` → `assistant/message`;TTFT 记录每步首个非空 delta chunk(在步内 `llm/retry` 后保留,与窗口 `resetForRetry` 对齐);解码时长覆盖首 token → 已组装消息、仅统计上报 usage 的步;`toolMs` 按 callId 配对 `tool/call` → `tool/result`,未解决的调用在 `turn/end` 时丢弃。首 token 谓词 `isTokenDelta` 移入 `@deepseek-ai/dsh-llm/message`(与其判别的 `StreamChunk` 类型同处),Host 折叠与客户端计时索引共用同一实现;client-runtime 转发导出。投递完全复用现有投影缝——history 尾页块、`session/projection` 推送帧、列表行——apiproxy、wire schema 与客户端运行时零改动。`StatsPills` 读取 `useProjection('sessionStats')`,键为 undefined(未组合该单元的装配)时整体回退到窗口折叠。客户端 connection fixture 按其「镜像每个已组合键」的既有纪律以 `sessionStatsOf` 平行实现该折叠。
计数事件选 `step/end` 而非 `assistant/message`,源于评审直觉方案(按消息计数)时发现的两个正确性问题:
diff --git a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml
index 3235d96b54..7546cce6a7 100644
--- a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.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/feature/2026-08-18-conversation-adaptive-content-width.md
-2026-08-18-conversation-adaptive-content-width.md: 9b0e7afbded296b60b7c283030a537321b36684e
-2026-08-18-conversation-adaptive-content-width.zh.md: f1f424cf9d4ac087518e9f79a365d0052069b999
+2026-08-18-conversation-adaptive-content-width.md: 50de28464181e97d434db9601053e690b36e5746
+2026-08-18-conversation-adaptive-content-width.zh.md: 6d9627060d9037c4bb5e3587f80ac62620fe2da9
diff --git a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md
index 9b0e7afbde..50de284641 100644
--- a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md
+++ b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.md
@@ -6,7 +6,7 @@ English | [中文](2026-08-18-conversation-adaptive-content-width.zh.md)
## Problem
-The conversation column's shared width axis (`--dsh-chat-content-width`) was the fixed figma constant 748px. On wide monitors (a 4000px display leaves a ~3500px column) the transcript occupied under a quarter of the column with dead margins on both sides. Every derived surface — the input card (W + 32px), dock cards, takeover panels, StatsLine, the back-to-bottom padding formula — rides this one variable, so any change had to keep the whole column's alignment relations intact. Alongside the adaptive default, users asked for direct control: hover the transcript's side margins to get a col-resize cursor and drag either edge, with both edges moving symmetrically.
+The conversation column's shared width axis (`--dsh-chat-content-width`) was the fixed figma constant 748px. On wide monitors (a 4000px display leaves a ~3500px column) the transcript occupied under a quarter of the column with dead margins on both sides. Every derived surface — the input card (W + 32px), dock cards, takeover panels, the stats strip, the back-to-bottom padding formula — rides this one variable, so any change had to keep the whole column's alignment relations intact. Alongside the adaptive default, users asked for direct control: hover the transcript's side margins to get a col-resize cursor and drag either edge, with both edges moving symmetrically.
## Decision
diff --git a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md
index f1f424cf9d..6d9627060d 100644
--- a/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-18-conversation-adaptive-content-width.zh.md
@@ -6,7 +6,7 @@ Status: implemented
## 问题
-会话列的共享宽度轴(`--dsh-chat-content-width`)是 figma 定值 748px。在宽显示器上(4000px 屏幕的会话列约 3500px)正文只占列宽不到四分之一,两侧是大片空白边距。所有派生表面——输入卡(W + 32px)、dock 卡片、takeover 面板、StatsLine、回底按钮的 padding 公式——都由这一个变量推导,任何改动都必须保持整列的对齐关系。在自适应默认值之外,用户还要求直接控制:hover 正文两侧边距出现 col-resize 光标,拖任一侧、两侧对称联动。
+会话列的共享宽度轴(`--dsh-chat-content-width`)是 figma 定值 748px。在宽显示器上(4000px 屏幕的会话列约 3500px)正文只占列宽不到四分之一,两侧是大片空白边距。所有派生表面——输入卡(W + 32px)、dock 卡片、takeover 面板、统计条、回底按钮的 padding 公式——都由这一个变量推导,任何改动都必须保持整列的对齐关系。在自适应默认值之外,用户还要求直接控制:hover 正文两侧边距出现 col-resize 光标,拖任一侧、两侧对称联动。
## 决策
diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml
index f0999e1aa3..04f90225a6 100644
--- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.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/feature/2026-08-18-settings-font-size-control.md
-2026-08-18-settings-font-size-control.md: 778893a2c0943f1d3af8905a27efb526071e073b
-2026-08-18-settings-font-size-control.zh.md: 71ff3b556622e7091891f8e18f5eefcbda2b0851
+2026-08-18-settings-font-size-control.md: 22eb3d42405a696610795ac1d863258c58512164
+2026-08-18-settings-font-size-control.zh.md: c90b46c10b7fcaa95859c3ec40d52dedadcf8351
diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md
index 778893a2c0..22eb3d4240 100644
--- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md
+++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.md
@@ -14,7 +14,7 @@ The conversation's body text size was fixed (14px after the 0.875 markdown-ladde
**Presentation rides the existing snapshot pipeline.** The service never touches the DOM: ui-layout's `ThemePresenter` writes `--dsh-content-font-size` on `body` from each snapshot (and retracts it on dispose), and the Host boot script embeds the durable value in the index response so first paint uses the chosen size — the same pre-plugin path the dark-mode attribute takes, avoiding a font-size flash.
-**One CSS delta variable moves the ladder, plus a derived secondary tier.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Secondary-tier text — one step under the body — reads `--dsh-content-font-size-secondary: min(setting − 1px, max(13px, setting − 2px))`: setting −1 at ≤14, setting −2 above (12→11, 13→12, 14→13, 15→13, 16→14, 17→15), with `--dsh-content-font-delta-secondary` (secondary − 13px) moving its line heights. The secondary tier covers the markdown table variants, the shared DisclosureRow title (tool calls, think, commands), ToolRow/bash-row summaries and file links, think text, compaction/context/retry/error rows, StatsLine, the chat hint and open-error strips, the workflow-run panel headers/counts/statuses, the turn-status clock, reference summaries, and the note-trigger label. Small and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder at the body size read `var(--dsh-content-font-size, 14px)` and `calc(
+ var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (inline reference glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), the compaction/DisclosureRow geometry (row height, leading box, expanded bodies' `22px + delta` indent), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The fallbacks (14px body, 13px secondary) keep every surface pixel-identical when the variables are absent (tests, storybook-like mounts, remote compositions before adoption).
+**One CSS delta variable moves the ladder, plus a derived secondary tier.** `gradient-shadow-text.css` derives `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)` and shifts the markdown h1–h4 and base variants (size and line height) by that same px increment, preserving the heading hierarchy and each variant's leading. Secondary-tier text — one step under the body — reads `--dsh-content-font-size-secondary: min(setting − 1px, max(13px, setting − 2px))`: setting −1 at ≤14, setting −2 above (12→11, 13→12, 14→13, 15→13, 16→14, 17→15), with `--dsh-content-font-delta-secondary` (secondary − 13px) moving its line heights. The secondary tier covers the markdown table variants, the shared DisclosureRow title (tool calls, think, commands), ToolRow/bash-row summaries and file links, think text, compaction/context/retry/error rows, StatsPills, the chat hint and open-error strips, the workflow-run panel headers/counts/statuses, the turn-status clock, reference summaries, and the note-trigger label. Small and code variants stay fixed — as does the interrupted-turn `.stopped` tag (11px): they are dense secondary text whose defaults would fall below legibility when stepped down. Consumers outside the token ladder at the body size read `var(--dsh-content-font-size, 14px)` and `calc( + var(--dsh-content-font-delta, 0px))` directly: the assistant narration root, the user bubble (inline reference glyphs included), the composer card (whose textarea/mirror/backdrop stack inherits font metrics from the card by design), the compaction/DisclosureRow geometry (row height, leading box, expanded bodies' `22px + delta` indent), the message clock and icon actions (slot-injected message-feedback actions match through the same variables), and the turn status line. Flow icons scale through each leading box's CSS edge (`svg` width/height overriding the glyph attributes); StateDot is exempt via its `data-state` attribute — a status mark, not text furniture. The fallbacks (14px body, 13px secondary) keep every surface pixel-identical when the variables are absent (tests, storybook-like mounts, remote compositions before adoption).
**The stepper is a pill, not a menu.** The row reuses the selector-pill geometry (h36 r18 module fill) with the value centered in the pill, the up/down arrow column revealed on hover/focus-within and absolutely anchored to the pill's right edge (so revealing never moves the value), and a `px` unit label after the pill. A tertiary description line under the title states the scope — the size only affects conversation content, not the application chrome. Arrows disable at the bounds; the display follows the store mirror, never the click echo — the same store/face pattern as the Appearance row.
diff --git a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md
index 71ff3b5566..c90b46c10b 100644
--- a/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md
+++ b/.agents/notes/implemented/feature/2026-08-18-settings-font-size-control.zh.md
@@ -14,7 +14,7 @@
**呈现走既有快照管线。**服务绝不接触 DOM:ui-layout 的 `ThemePresenter` 依据每份快照在 `body` 上写 `--dsh-content-font-size`(dispose 时收回),Host 引导脚本把持久化值嵌入 index 响应,让首帧就使用所选字号——与暗色属性同一条插件前路径,避免字号闪变。
-**一个 CSS 增量变量平移阶梯,另派生一个低一档层级。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。低一档文本——比正文低一档——读 `--dsh-content-font-size-secondary: min(设置值 − 1px, max(13px, 设置值 − 2px))`:设置 ≤14 时为设置值 −1,>14 时为设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15),其行高经 `--dsh-content-font-delta-secondary`(低一档值 − 13px)随动。低一档覆盖:markdown 表格变体、共享 DisclosureRow 标题(工具调用、think、命令)、ToolRow/bash 行的 summary 与文件链接、think 正文、compaction/context/retry/错误行、StatsLine、chat 提示与打开失败条、workflow-run 面板的头部/计数/状态、回合状态时钟、引用摘要与笔记触发标签。small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外按正文档读取的消费方直接使用 `var(--dsh-content-font-size, 14px)` 与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含行内引用字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量)、compaction/DisclosureRow 的行几何(行高、leading 盒、展开内容的 `22px + delta` 缩进)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。回退值(正文 14px、低一档 13px)让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。
+**一个 CSS 增量变量平移阶梯,另派生一个低一档层级。**`gradient-shadow-text.css` 派生 `--dsh-content-font-delta: calc(var(--dsh-content-font-size, 14px) - 14px)`,把 markdown h1–h4 与 base 各变体(字号与行高)按同一像素增量平移,保持标题层级与各变体的行距。低一档文本——比正文低一档——读 `--dsh-content-font-size-secondary: min(设置值 − 1px, max(13px, 设置值 − 2px))`:设置 ≤14 时为设置值 −1,>14 时为设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15),其行高经 `--dsh-content-font-delta-secondary`(低一档值 − 13px)随动。低一档覆盖:markdown 表格变体、共享 DisclosureRow 标题(工具调用、think、命令)、ToolRow/bash 行的 summary 与文件链接、think 正文、compaction/context/retry/错误行、StatsPills、chat 提示与打开失败条、workflow-run 面板的头部/计数/状态、回合状态时钟、引用摘要与笔记触发标签。small 与 code 变体保持固定——中断回合的 `.stopped` 标签(11px)同样固定:它们是密集次级文本,其默认值在字号下调时会低于可读下限。token 阶梯之外按正文档读取的消费方直接使用 `var(--dsh-content-font-size, 14px)` 与 `calc(<默认行高> + var(--dsh-content-font-delta, 0px))`:助手正文根节点、用户气泡(含行内引用字形)、composer 卡片(其 textarea/mirror/backdrop 三层按设计从卡片继承字体度量)、compaction/DisclosureRow 的行几何(行高、leading 盒、展开内容的 `22px + delta` 缩进)、消息时钟与图标操作(slot 注入的消息反馈操作经同一对变量同步缩放),以及回合状态行。流内图标经由各 leading 盒的 CSS 边长缩放(`svg` width/height 覆盖字形自身属性);StateDot 通过其 `data-state` 属性豁免——它是状态标记,不是文字组件。回退值(正文 14px、低一档 13px)让变量缺席时(测试、独立挂载、采纳前的远程组合)所有表面逐像素不变。
**步进器是药丸控件,不是菜单。**该行复用选择器药丸几何(h36 r18 模块填充),数值在药丸内居中,上下箭头列在 hover/focus-within 时显示并绝对定位在药丸右缘(显示时数值不移动),药丸后带 `px` 单位标签。标题下方的三级说明行标明作用范围——字号仅影响会话内容,不影响应用外框。到达边界时对应箭头禁用;显示跟随 store 镜像,绝不跟随点击回声——与外观行相同的 store/face 模式。
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..01ce7964cd
--- /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: 577da02cc53e0df8577909d20e12be240cdd4eb5
+2026-09-07-composer-session-stats-pills.zh.md: 0c14060c458ffedd62333625553c63fadc7f417f
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..577da02cc5
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.md
@@ -0,0 +1,32 @@
+# 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, token totals with cache-hit share, and context occupancy. The line crowded as figures accumulated, exact token values hid behind a hover tooltip with its own `ResizeObserver` overflow measurement, 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.elapsed` / `stats.tps` 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 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) extracted for exactly this two-consumer split.
+- **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](../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 left the strip entirely — the composer's ContextMeter ring owns 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 for exact totals. Lost on crowding and discoverability: the tooltip hid exact values behind an unadvertised hover, 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.
+- **Keeping context occupancy in the strip.** Rejected: the ContextMeter ring beside the composer already shows occupancy continuously; repeating it in the strip spent width on a duplicate.
+- **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 figures cost a click instead of a hover; the strip itself carries only the two headline readings.
+- Web e2e strip assertions match substring text inside the time pill; the paged-history scenario's aria golden pins the two-pill structure.
+- 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..0c14060c45
--- /dev/null
+++ b/.agents/notes/implemented/feature/2026-09-07-composer-session-stats-pills.zh.md
@@ -0,0 +1,32 @@
+# 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.elapsed` / `stats.tps` 文案键一并移除。
+
+- **两个图标 pill、两个弹层。** 仪表盘 pill(新增 `IconGaugeOutline16`,因下开口圆弧视觉偏高而把表盘中心光学下移到 y=8.75)展示 `{turns} 轮 {steps} 步` 加输出 TPS,点击打开「会话统计」弹层(模型用时、工具调用用时、首 token 平均、输出速度)。数据库 pill(`IconDatabaseOutline16`)展示紧凑计费总量加缓存命中率,点击打开「Token 用量」弹层(缓存命中、未缓存输入、缓存读取、缓存写入、输出——精确计数)。两个弹层共用为这两处消费者抽出的 `stat-dialog` 模块(portal 面板、锚定定位、点击外部关闭)。
+- **数据来源架构不变。** 计数与用时优先读取持久的 `sessionStats` 投影,窗口折叠仅作无该单元装配时的回退([全会话计数](../bug-fix/2026-08-12-full-session-turn-step-counts.zh.md));token 数字只走 `tokenUsage`,投影缺席时直接不渲染用量 pill,而非展示窗口推算的计费。缓存写入仍计入计费总量与缓存命中分母([投影决定](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。上下文占用彻底离开统计条——由输入框旁的 ContextMeter 圆环独占。
+- **渲染纪律。** 该行只折叠已定稿节点(`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 保持为三个内联分组。双 pill 胜出是因为时间/用量的切分与两个底层投影一一对应,且每个 pill 的图标直接预告其弹层内容。
+- **在统计条里保留上下文占用。** 否决:输入框旁的 ContextMeter 圆环已持续展示占用,统计条重复它是把宽度花在冗余上。
+- **抽出与 `TurnUsagePanel` 共享的 dl 分桶行。** 会话总量弹层与逐轮面板皮肤相同但契约不同(此处所有分桶恒在;逐轮字段可选且含模型路由);共享组件只是给九行代码包一层条件。该镜像以 `jscpd:ignore` 标注并内联说明理由。
+
+## 后果
+
+- `ChatSnapshotBuilder` 的 legacy 切片现在服务于 StatsPills;[节点装配 note](../architecture/2026-08-09-client-conversation-node-assembly.zh.md) 已跟进该消费者更名。
+- 精确数字从悬停改为一次点击;统计条本身只承载两个头条读数。
+- Web e2e 对统计条的断言匹配时间 pill 内的子串文本;分页历史场景的 aria golden 钉住双 pill 结构。
+- 生成的插槽目录中 `conversation.composer.dock` 占用者为 `client-ui-chat StatsPills id 'stats'`。
diff --git a/apps/web/tests/command-image-envelope.expected.e2e.ts b/apps/web/tests/command-image-envelope.expected.e2e.ts
index 36c25232fd..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, run / 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 b0ff0e6411..da6d1f3794 100644
--- a/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
+++ b/apps/web/tests/expected/clickable-links-gallery/ui.expected.md
@@ -191,7 +191,7 @@
- text: {{clock}}
- button "Back to bottom":
- img
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -201,4 +201,6 @@
- text: DeepSeek-V4-Flash
- img
- button "Send message" [disabled]
-- text: 1 turns · 1 steps LLM {{duration}} · Tool call {{duration}}
+- button "1 turns 1 steps":
+ - img
+ - text: 1 turns 1 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 521443e706..cd24ce7478 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
@@ -48,7 +48,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -58,4 +58,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 089e6206b4..707362508e 100644
--- a/apps/web/tests/expected/github-ready-review/conversation.expected.md
+++ b/apps/web/tests/expected/github-ready-review/conversation.expected.md
@@ -40,7 +40,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -50,4 +50,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 5a991063cd..92624aee4d 100644
--- a/apps/web/tests/expected/goal-command-presentation/ui.expected.md
+++ b/apps/web/tests/expected/goal-command-presentation/ui.expected.md
@@ -14,7 +14,7 @@
- img
- img
- text: "goal No goal is currently set. Usage: /goal [|clear|edit |pause|resume]"
-- textbox "Message or run a task, run / 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 bff877489a..69c7062a46 100644
--- a/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
+++ b/apps/web/tests/expected/markdown-cjk-strong/ui.expected.md
@@ -48,7 +48,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -58,4 +58,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 307ea19de3..637700cf64 100644
--- a/apps/web/tests/expected/markdown-images/ui.expected.md
+++ b/apps/web/tests/expected/markdown-images/ui.expected.md
@@ -27,7 +27,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -37,4 +37,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 4f0d02556c..c6425f205a 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
@@ -39,7 +39,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / 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}}
+- 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 3898fca4b0..d4d5cbc87c 100644
--- a/apps/web/tests/expected/math-rendering/ui.expected.md
+++ b/apps/web/tests/expected/math-rendering/ui.expected.md
@@ -43,7 +43,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -53,4 +53,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 192e5157d0..9f9f643e70 100644
--- a/apps/web/tests/expected/reference-composer/order.expected.md
+++ b/apps/web/tests/expected/reference-composer/order.expected.md
@@ -13,7 +13,7 @@
- button "Session recall Research notes":
- img
- text: Session recall Research notes
-- textbox "Message or run a task, run / 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 a99a0b3fa3..33c4a66a1c 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
@@ -40,7 +40,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / 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 · 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 tokCache 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 604c97db5f..47bcd5d694 100644
--- a/apps/web/tests/expected/skill-user-invoke/ui.expected.md
+++ b/apps/web/tests/expected/skill-user-invoke/ui.expected.md
@@ -32,7 +32,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -43,4 +43,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 tokCache 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 4341818041..ce18156e22 100644
--- a/apps/web/tests/expected/stats-paged-history/ui.expected.md
+++ b/apps/web/tests/expected/stats-paged-history/ui.expected.md
@@ -459,7 +459,7 @@
- text: 7/25 {{clock}}
- button "Back to bottom":
- img
-- textbox "Message or run a task, run / commands, @ files or sessions"
+- textbox "Message or run a task, / commands, @ files or sessions"
- button "Commands":
- img
- button "Add attachment":
@@ -469,4 +469,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 fe5e529df5..781f5be768 100644
--- a/apps/web/tests/expected/steer-all/mid-steer.expected.md
+++ b/apps/web/tests/expected/steer-all/mid-steer.expected.md
@@ -31,7 +31,7 @@
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy":
- img
-- textbox "Message or run a task, run / 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 6939a3c0ba..bb60fc9ee1 100644
--- a/apps/web/tests/expected/steer-all/settled-expanded.expected.md
+++ b/apps/web/tests/expected/steer-all/settled-expanded.expected.md
@@ -50,7 +50,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / 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 "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 tokCache 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 39547a7fa7..66145a7073 100644
--- a/apps/web/tests/expected/steer-all/settled.expected.md
+++ b/apps/web/tests/expected/steer-all/settled.expected.md
@@ -38,7 +38,7 @@
- img
- text: Ran for {{duration}}
- text: {{clock}}
-- textbox "Message or run a task, run / 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 "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 tokCache hit 0%":
+ - img
+ - text: 40 tokCache hit 0%
diff --git a/apps/web/tests/goal-bar.e2e.ts b/apps/web/tests/goal-bar.e2e.ts
index 5b077016e5..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, run / 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 34481c4a17..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, run / 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, run / 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, run / 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 3abee08a82..d83a9c8758 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/preview-boot.e2e.ts b/apps/web/tests/preview-boot.e2e.ts
index 7b5f49c1c0..285e3aaa66 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, run / 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 461c34a9d1..c5d7728978 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
@@ -24,7 +24,7 @@
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- status: Deep diving...
-- textbox "Message or run a task, run / 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 7e7065e6b2..882b99b420 100644
--- a/apps/web/tests/startup-auto-selection.e2e.ts
+++ b/apps/web/tests/startup-auto-selection.e2e.ts
@@ -130,7 +130,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, run / 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 af7407778a..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, run / 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, run / 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, run / 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/subagent-interrupt-ui.e2e.ts b/apps/web/tests/subagent-interrupt-ui.e2e.ts
index bf770c3874..cd155bda39 100644
--- a/apps/web/tests/subagent-interrupt-ui.e2e.ts
+++ b/apps/web/tests/subagent-interrupt-ui.e2e.ts
@@ -263,7 +263,7 @@ describe.skipIf(MODE === 'record')('web e2e: composer interrupt for a running co
.getByRole('button').first().click()
await page.getByRole('button', { name: /1 subagent/ }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).click()
- const input = page.getByRole('textbox', { name: 'Message or run a task, run / 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 })
expect(await input.isDisabled()).toBe(false)
diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts
index 252ef70ee7..c52e60c791 100644
--- a/apps/web/tests/support.ts
+++ b/apps/web/tests/support.ts
@@ -112,7 +112,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, run / 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 })
}
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-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts
index 0c7d87f344..31cfd00696 100644
--- a/packages/client/ui-chat/src/client/apply.ts
+++ b/packages/client/ui-chat/src/client/apply.ts
@@ -23,7 +23,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 { DetailsPanel } from './details/DetailsPanel.tsx'
import { en, NS, zh } from './locale.ts'
@@ -155,7 +155,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..a3e9d5a901
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/StatsPills.module.css
@@ -0,0 +1,63 @@
+/* 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;
+ cursor: pointer;
+}
+
+.pill svg {
+ width: 14px;
+ height: 14px;
+ flex: none;
+}
+
+.pill:hover,
+.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..14f4f8623c
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/StatsPills.tsx
@@ -0,0 +1,299 @@
+// 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 } 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) })
+}
+
+function TimePill({ stats, t }: { stats: WindowStats; t: ChatViewSlotProps['t'] }) {
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
+ 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 }: { usage: TokenUsageProjection; t: ChatViewSlotProps['t'] }) {
+ const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
+ // Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
+ const total = billedInputTokens(usage) + usage.outputTokens
+ const cacheHit = cacheHitPercent(usage)
+ 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')
+ // 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 && }
+ {hasTokens && }
+
+ )
+})
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..0d4d910bfa
--- /dev/null
+++ b/packages/client/ui-chat/src/client/chat/stat-dialog.ts
@@ -0,0 +1,62 @@
+// 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.
+ * @returns the seat; spread `pos ?? MEASURE_STYLE` onto the portaled panel.
+ */
+export 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 }
+}
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 f10a9ced20..72a0b9c74e 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)',
'details.title': '详情',
'details.close': '关闭详情',
'details.empty': '点击消息流中的工具行查看详情',
@@ -127,13 +128,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)',
'details.title': 'Details',
'details.close': 'Close details',
'details.empty': 'Click a tool row in the message flow to view its details',
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..8f80f2dc84 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,14 @@ describe('small branch tails', () => {
: undefined}
/>,
)
- expect(view.container.textContent).toBe('1 轮 · 1 步| 输入 0 tok · 输出 10 tok')
+ const [timePill, usagePill] = [...view.getAllByRole('button')] as [HTMLElement, HTMLElement]
+ expect(timePill.textContent).toBe('1 轮 1 步')
+ 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..dc4a05984e 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,31 @@ 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 and usage pills 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. Cache hit comes
+ // from the projection, so paging the window cannot change it; the usage
+ // pill leads with the whole-log token total.
+ expect(view.getAllByRole('button').map(pill => pill.textContent)).toEqual(
+ ['1 turns 1 steps', '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 +198,206 @@ 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')[1]!.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)
+ it('exposes output speed on the counts pill when decode timing exists', () => {
+ const { source } = makeSource({ nodes: [timedStep()] })
+ const view = render()
+ expect(view.getAllByRole('button')[0]!.textContent).toBe('1 turns 1 steps·20 tok/s')
+ })
+
+ 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('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('closes the dialog on Escape or outside pointerdown', () => {
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')
+ 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('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('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('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('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('renders without ResizeObserver support', () => {
- vi.unstubAllGlobals()
- const { source } = makeSource({ nodes: [assistant(1, 1)] })
- expect(() => render()).not.toThrow()
- })
-
- it('keeps durable token groups after the visible step window is empty', () => {
+ 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')
+ expect(view.getAllByRole('button')).toHaveLength(1)
})
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.getAllByRole('button')[0]!.textContent).toBe('10 turns 89 steps')
})
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.getAllByRole('button')).toHaveLength(1)
})
- 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.getAllByRole('button')[0]!.textContent).toBe('7 turns 44 steps')
})
- 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')[1]!
+ expect(usagePill.textContent).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')[1]!.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 9f7d3dd062..dbe280b840 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,6 +1,6 @@
// @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 { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
@@ -18,7 +18,7 @@ import type {
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { createChatStore } from '../src/client/stores.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 { DetailsPanel } from '../src/client/details/DetailsPanel.tsx'
import { zh } from '../src/client/locale.ts'
import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
@@ -26,14 +26,6 @@ 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()
@@ -92,7 +84,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
@@ -105,13 +97,14 @@ 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 步')
+ expect(view.getAllByRole('button')).toHaveLength(1)
})
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 6f94a41dd5..5d01c9d345 100644
--- a/packages/client/ui-conversation/src/client/locales.ts
+++ b/packages/client/ui-conversation/src/client/locales.ts
@@ -176,10 +176,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, run / 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, run / 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',
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 1749d469d9..1287118622 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
@@ -158,7 +165,10 @@
bar inset. */
.input {
box-sizing: border-box;
- padding: 2px 8px 0 14px;
+ /* 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;
@@ -189,7 +199,7 @@
Insets mirror .input's pads (the other right 4px sits on .scroll). */
.placeholder {
position: absolute;
- inset: 2px 8px auto 14px;
+ inset: 4px 8px auto 14px;
color: var(--dsw-alias-label-caption);
pointer-events: none;
user-select: none;
@@ -219,8 +229,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
diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
index f375ade667..b7b212b609 100644
--- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx
+++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx
@@ -127,6 +127,9 @@ export function ModelSelect(
// 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 Menu's clamped fixed
+ placement; this select keeps its own pane/reload lifecycle instead of adopting
+ Menu's item model, so only the measure-and-clamp plumbing repeats. */
useLayoutEffect(() => {
if (!open) { setMenuPos(null); return }
const place = (): void => {
@@ -152,6 +155,7 @@ export function ModelSelect(
window.removeEventListener('resize', place)
}
}, [open, pane, state])
+ /* jscpd:ignore-end */
if (!available) return null
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) => (