Browse Source

fix(web): localize the context panel headline order and drop empty bar parts

The panel header concatenated a `45%` span with a `context.used` fragment, so
Chinese rendered "45% 上下文已用" against the ring's own "上下文已用 45%". The
header now renders the one localized `context.aria` sentence split around its
`{percent}` slot: each locale owns the reading's position while the reading
keeps its primary tone, and the side a locale leaves empty collapses through
`.headline:empty` instead of spending a header gap.

The bar mapped every composition row to a segment unconditionally, and
`.segment`'s 2px min-width kept each one visible, so a 0% occupancy panel
painted an ~8px filled bar over an empty context. Parts are now computed with
their widths and zero-width parts are filtered out, which also collapses the
plain and segmented branches into one map.
Yichen Jiang 1 month ago
parent
commit
46562b2c30

+ 2 - 2
.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.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-05-composer-context-meter-breakdown.md
-2026-08-05-composer-context-meter-breakdown.md: 2879030aa7859385b2e6026fbf87da4548c392bd
-2026-08-05-composer-context-meter-breakdown.zh.md: 3aeff68ebd157c4104fc085ad19e1738ead8f983
+2026-08-05-composer-context-meter-breakdown.md: 431415f8463471f076df8f19e6c853909484f295
+2026-08-05-composer-context-meter-breakdown.zh.md: 71eea5fa657418297d49fbc0827bdd933b279c2d

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md

@@ -16,7 +16,7 @@ Three cooperating pieces, one per package boundary:
 
 `dsh-token-meter` extracts its pricing heuristic into `src/estimate.ts` — shared verbatim by the measurement service — and registers a third session projection, `contextBreakdown`, carrying `systemTokens` / `toolsTokens` / `messageTokens`. Envelope figures reprice last-wins on each `request/header` through `canonicalHeader`; the message figure folds surface appends and positional replacements over a per-node `{seq, tokens}` list, so compaction shrinks it the same way it shrinks the next request. A replace range absent from the folded surface throws: committed logs are surface-validated at append time, so an unresolvable range is log corruption, not a skippable event.
 
-`ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the ring, header, and bar length stay provider-exact while the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code.
+`ui-conversation` moves context occupancy off the stats line (one home per fact) onto a composer-trailing `ContextMeter`: a 14px occupancy ring after the model seat fed by `contextPressure`, click-opening a panel that pairs the provider-exact percent and `~used / capacity` header with a 4px color-segmented bar and `~`-prefixed composition rows. The two vocabularies deliberately never reconcile — the ring, header, and bar length stay provider-exact while the heuristic shares only proportion the bar's colored segments and rows, each marked `~` because the fixed 4-chars-per-token heuristic systematically underprices CJK text and code. The header is one localized sentence (`context.aria`, shared with the ring's accessible name) split around its `{percent}` slot, so each locale owns the reading's position — English leads with it, Chinese trails it — while the reading keeps its own tone; a bar part whose width computes to zero is dropped rather than rendered, because `.segment`'s min-width would otherwise paint a filled sliver at 0% occupancy.
 
 ## Alternatives considered
 

+ 1 - 1
.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md

@@ -16,7 +16,7 @@ Web 聊天的统计行把上下文占用率作为一个行内数字(`Context N
 
 `dsh-token-meter` 把计价启发式抽取到 `src/estimate.ts`(与测量服务逐字共享),并注册第三个会话投影 `contextBreakdown`,携带 `systemTokens` / `toolsTokens` / `messageTokens`。envelope 数字在每条 `request/header` 上经 `canonicalHeader` 按后者胜重新计价;消息数字用逐节点 `{seq, tokens}` 列表折叠表层追加与位置替换,因此压缩会像缩小下一个请求那样缩小它。折叠表层中不存在的替换范围会直接抛出:已提交日志在追加时就经过表层校验,无法解析的范围是日志损坏,而不是可跳过的事件。
 
-`ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题,与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——圆环、标题与进度条总长保持提供方精确值,启发式占比只用于切分进度条的彩色分段与明细行,且每个启发式数字都标 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。
+`ui-conversation` 把上下文占用率从统计行移走(一个事实一个家),放到 composer 尾部的 `ContextMeter`:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,点击弹出的面板把提供方精确的百分比与 `~已用 / 容量` 标题,与 4px 分色分段进度条及带 `~` 前缀的组成明细行并列。两套口径刻意永不对账——圆环、标题与进度条总长保持提供方精确值,启发式占比只用于切分进度条的彩色分段与明细行,且每个启发式数字都标 `~`,因为固定的「4 字符≈1 token」启发式会系统性低估 CJK 文本与代码。标题是一整句本地化文案(`context.aria`,与圆环的无障碍名共用),在 `{percent}` 槽位处切开渲染,于是读数的位置由各语言自己决定——英文在前、中文在后——同时读数保留自己的字重;宽度算出为零的分段直接不渲染,否则 `.segment` 的 min-width 会在 0% 占用时画出一段填充色。
 
 ## 备选方案
 

+ 2 - 2
packages/client/ui-conversation/src/client/locales.ts

@@ -23,7 +23,7 @@ export const zh = {
   'input.stop': '停止生成',
   'input.send': '发送消息',
   'input.accessMode': '访问模式,当前:{name}',
-  'context.aria': '上下文已用 {percent}%',
+  'context.aria': '上下文已用 {percent}',
   'context.used': '上下文已用',
   'context.system': '系统提示词',
   'context.tools': '工具',
@@ -143,7 +143,7 @@ export const en = {
   'input.stop': 'Stop generating',
   'input.send': 'Send message',
   'input.accessMode': 'Access mode, current: {name}',
-  'context.aria': '{percent}% of context used',
+  'context.aria': '{percent} of context used',
   'context.used': 'of context used',
   'context.system': 'System prompt',
   'context.tools': 'Tools',

+ 6 - 0
packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css

@@ -77,6 +77,12 @@
   color: var(--dsw-alias-label-tertiary);
 }
 
+/* The headline brackets the reading, so the side a locale leaves empty must
+   drop out of the flex row rather than spend a gap. */
+.headline:empty {
+  display: none;
+}
+
 .bar {
   display: flex;
   gap: 1px;

+ 32 - 17
packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx

@@ -17,6 +17,13 @@ import css from './ContextMeter.module.css'
 const RADIUS = 5.5
 const CIRCUMFERENCE = 2 * Math.PI * RADIUS
 
+/**
+ * Marker the localized occupancy sentence is split on, so the panel headline
+ * keeps the reading in its own tone while each locale still owns the word
+ * order (`45% of context used` / `上下文已用 45%`).
+ */
+const READING_SLOT = '\u0000'
+
 /** Panel legend rows, in bar-segment order; each color class carries the shared swatch/segment tint. */
 const ROWS = [
   { key: 'systemTokens', label: 'context.system', color: css.colorSystem },
@@ -57,23 +64,30 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
   const context = contextOccupancy(pressure)
   if (context === null) return null
   const percent = context.percent
+  const reading = `${percent}%`
+  const [headBefore = '', headAfter = ''] = t('context.aria', { percent: READING_SLOT })
+    .split(READING_SLOT)
+    .map(part => part.trim())
 
   // The bar's overall length stays the provider-exact percent; the heuristic
-  // breakdown only proportions its colored segments.
+  // breakdown only proportions its colored parts. A zero-width part is dropped
+  // instead of rendered: `.segment`'s min-width keeps a hairline part visible,
+  // which at 0% occupancy would draw a filled bar over an empty context.
   const breakdownTotal = breakdown === undefined
     ? 0
     : breakdown.systemTokens + breakdown.toolsTokens + breakdown.messageTokens
-  const segments = breakdown === undefined || breakdownTotal === 0
-    ? null
-    : ROWS.map(row => ({ key: row.key, color: row.color, share: breakdown[row.key] / breakdownTotal }))
+  const parts = breakdown === undefined || breakdownTotal === 0
+    ? [{ key: 'total', color: undefined, width: percent }]
+    : ROWS.map(row => ({ key: row.key, color: row.color, width: percent * breakdown[row.key] / breakdownTotal }))
+  const segments = parts.filter(part => part.width > 0)
 
   return (
     <span ref={rootRef} className={css.root}>
-      <Tooltip label={t('context.aria', { percent })} side="top" delayMs={200} disabled={open}>
+      <Tooltip label={t('context.aria', { percent: reading })} side="top" delayMs={200} disabled={open}>
         <button
           type="button"
           className={css.trigger}
-          aria-label={t('context.aria', { percent })}
+          aria-label={t('context.aria', { percent: reading })}
           aria-haspopup="dialog"
           aria-expanded={open}
           onClick={() => { setOpen(!open) }}
@@ -94,22 +108,23 @@ export function ContextMeter({ useProjection, t }: ContextMeterProps) {
       {open && (
         <div className={css.panel} role="dialog" aria-label={t('context.used')}>
           <div className={css.header}>
-            <span className={css.percent}>{`${percent}%`}</span>
-            <span className={css.headline}>{t('context.used')}</span>
+            {/* Empty sides collapse through `.headline:empty` so the locale that
+                needs no leading (or trailing) text spends no header gap. */}
+            <span className={css.headline}>{headBefore}</span>
+            <span className={css.percent}>{reading}</span>
+            <span className={css.headline}>{headAfter}</span>
             <span className={css.figures}>
               {`~${formatTokens(context.pressureTokens)} / ${formatTokens(context.contextWindow)}`}
             </span>
           </div>
           <div className={css.bar}>
-            {segments === null
-              ? <div className={css.segment} style={{ width: `${percent}%` }} />
-              : segments.map(segment => (
-                <div
-                  key={segment.key}
-                  className={`${css.segment} ${segment.color}`}
-                  style={{ width: `${percent * segment.share}%` }}
-                />
-              ))}
+            {segments.map(segment => (
+              <div
+                key={segment.key}
+                className={segment.color === undefined ? css.segment : `${css.segment} ${segment.color}`}
+                style={{ width: `${segment.width}%` }}
+              />
+            ))}
           </div>
           {breakdown !== undefined && (
             <dl className={css.rows}>

+ 35 - 4
packages/client/ui-conversation/tests/context-meter.spec.tsx

@@ -5,15 +5,16 @@
 import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, fireEvent, render } from '@testing-library/react'
 import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
-import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
+import { en as commonEn, zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/index.ts'
 import { ContextMeter, type ContextMeterProps } from '../src/client/skeleton/ContextMeter.tsx'
 import css from '../src/client/skeleton/ContextMeter.module.css'
-import { zh } from '../src/client/locales.ts'
+import { en, zh } from '../src/client/locales.ts'
 
 afterEach(cleanup)
 
 // Mirrors the real lookup chain (conversation namespace, then common).
 const t = makeTranslate(zh, commonZh) as ContextMeterProps['t']
+const tEn = makeTranslate(en, commonEn) as ContextMeterProps['t']
 
 const BREAKDOWN = { systemTokens: 120, toolsTokens: 21_500, messageTokens: 477_000 }
 
@@ -25,8 +26,8 @@ function projections(values: Record<string, unknown>): ContextMeterProps['usePro
   return (key: string) => values[key]
 }
 
-function meter(values: Record<string, unknown>) {
-  return render(<ContextMeter useProjection={projections(values)} t={t} />)
+function meter(values: Record<string, unknown>, translate: ContextMeterProps['t'] = t) {
+  return render(<ContextMeter useProjection={projections(values)} t={translate} />)
 }
 
 describe('ContextMeter', () => {
@@ -58,6 +59,36 @@ describe('ContextMeter', () => {
     expect(view.container.querySelector('[role="dialog"]')).toBeNull()
   })
 
+  it('lets each locale own the headline word order around the reading', () => {
+    const values = {
+      contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 },
+      contextBreakdown: BREAKDOWN,
+    }
+    const zhView = meter(values)
+    fireEvent.click(zhView.getByRole('button', { name: '上下文已用 25%' }))
+    // The reading follows the label in Chinese and leads it in English; both
+    // headers read as one sentence rather than a concatenated fragment.
+    expect(zhView.container.querySelector('[role="dialog"]')!.textContent)
+      .toMatch(/^上下文已用25%/)
+    const enView = meter(values, tEn)
+    fireEvent.click(enView.getByRole('button', { name: '25% of context used' }))
+    expect(enView.container.querySelector('[role="dialog"]')!.textContent)
+      .toMatch(/^25%of context used/)
+  })
+
+  it('draws no bar segment at zero occupancy', () => {
+    const view = meter({
+      contextPressure: { pressureTokens: 0, contextWindow: 128_000 },
+      contextBreakdown: BREAKDOWN,
+    })
+    fireEvent.click(view.getByRole('button', { name: '上下文已用 0%' }))
+    const panel = view.container.querySelector('[role="dialog"]')!
+    // `.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')
+  })
+
   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%' }))