From 46562b2c30f13cbde511ae84cf4f1987846fd233 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Wed, 5 Aug 2026 15:59:24 +0800 Subject: [PATCH] fix(web): localize the context panel headline order and drop empty bar parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...composer-context-meter-breakdown.i18n.yaml | 4 +- ...-08-05-composer-context-meter-breakdown.md | 2 +- ...-05-composer-context-meter-breakdown.zh.md | 2 +- .../ui-conversation/src/client/locales.ts | 4 +- .../client/skeleton/ContextMeter.module.css | 6 +++ .../src/client/skeleton/ContextMeter.tsx | 49 ++++++++++++------- .../tests/context-meter.spec.tsx | 39 +++++++++++++-- 7 files changed, 79 insertions(+), 27 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml index c0727e7f3b..7a1a84bc0c 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md index 2879030aa7..431415f846 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.md +++ b/.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 diff --git a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md b/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md index 3aeff68ebd..71eea5fa65 100644 --- a/.agents/notes/implemented/feature/2026-08-05-composer-context-meter-breakdown.zh.md +++ b/.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% 占用时画出一段填充色。 ## 备选方案 diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 344b2b64c7..09cf15a779 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/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', diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css index 9ded4d91c2..9eed743214 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.module.css +++ b/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; diff --git a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx b/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx index 1ce40d3e87..c4ea6f78cd 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ContextMeter.tsx +++ b/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 ( - +