From a4da0f40d5e4ac11990ee0f6485f39ae2def66aa Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 14:23:54 +0800 Subject: [PATCH 01/34] feat(web): preserve near-full cache-hit precision --- ...9-high-cache-hit-decimal-display.i18n.yaml | 6 + ...26-08-19-high-cache-hit-decimal-display.md | 50 ++++++ ...08-19-high-cache-hit-decimal-display.zh.md | 50 ++++++ apps/web/tests/lifecycle-chrome.e2e.ts | 8 +- .../lifecycle-chrome/reloaded.expected.md | 4 +- .../lifecycle-chrome/replay.override.json | 153 ++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/chat/StatsLine.tsx | 32 +++- .../tests/chat-stats.client.spec.tsx | 33 +++- 11 files changed, 325 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md create mode 100644 .agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md create mode 100644 apps/web/tests/snapshots/lifecycle-chrome/replay.override.json diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml new file mode 100644 index 0000000000..b95a27e700 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.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-08-19-high-cache-hit-decimal-display.md +2026-08-19-high-cache-hit-decimal-display.md: 29e0ebc11ae690b918c7e9364f29635de1e50f2a +2026-08-19-high-cache-hit-decimal-display.zh.md: 28cd99a4c321cc4da0ce03142e2fbf438539b0ff diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md new file mode 100644 index 0000000000..29e0ebc11a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md @@ -0,0 +1,50 @@ +# Agent Note: High cache-hit decimal display + +Status: implemented + +English | [中文](2026-08-19-high-cache-hit-decimal-display.zh.md) + +## Problem + +The Web conversation stats line rounded every non-empty cache-hit ratio to an integer. Once the actual ratio passed 99%, the display hid further progress, and a ratio of at least 99.5% appeared as 100% even while uncached input or cache writes remained. + +Users therefore could not distinguish a nearly complete cache hit from a true full hit. + +## Decision + +`StatsLine` continues to derive the ratio from the whole-session `tokenUsage` projection owned by `@deepseek-ai/dsh-token-meter`; the projection remains the only owner of the uncached-input, cache-read, cache-write, and output counts ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). The presentation layer changes only the text inserted into the existing `stats.cacheHit` locale template. + +| Actual ratio | Display | +|---|---| +| No billed input | Cache-hit group omitted | +| Integer rounding is below 100% | Rounded integer | +| Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% | +| 100% | `100%` | + +Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` scales and rounds the integer token counts with `bigint`, which avoids floating-point formatting limits and imposes no precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip. + +## Ownership and lifecycle + +Token-meter continues to fold usage from the complete durable session log. `StatsLine` performs a synchronous display derivation whenever the standard projection value changes. It introduces no setting, stored percentage, event, wire field, client state, or recovery path. + +Live updates, reload replay, and reconnect recovery all restore the same `tokenUsage` counts and run the same display function. A missing projection still omits every token group, and a zero input denominator still omits only the cache-hit group. + +## Verification + +The component spec pins the zero denominator, ordinary integer rounding, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call. + +## Alternatives considered + +**Keep integer rounding for every ratio.** Rejected because it hides all movement above 99% and still reports some non-full hits as 100%. + +**Truncate the high band to one decimal.** Rejected because `99.95%`, `99.995%`, and still closer ratios all collapse to `99.9%` instead of retaining the minimum precision that distinguishes them from a full hit. + +**Cap precision and use a substitute such as `<100%`.** Rejected because the exact cumulative counts can produce the required numeric result, and a cap would make display behavior depend on an arbitrary presentation limit. + +**Show one decimal at every ratio.** Rejected because the additional low-band motion adds noise and changes the established display where integer precision is sufficient. + +**Persist a display percentage in token-meter.** Rejected because the projection already carries the exact counts, while presentation precision belongs to the Web stats line. A second stored value would duplicate derivable state and expand replay and wire responsibilities. + +## Consequences + +High cache-hit sessions remain visually stable until integer rounding would falsely report a full hit, then expose only the decimal places needed to preserve that distinction. Extremely close non-full ratios can therefore produce long decimal strings; this is the accepted cost of having no arbitrary precision cap or nonnumeric fallback. Every delivery and recovery path stays on the existing durable projection lifecycle. diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md new file mode 100644 index 0000000000..28cd99a4c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md @@ -0,0 +1,50 @@ +# Agent Note: 高缓存命中率的小数显示 + +Status: implemented + +[English](2026-08-19-high-cache-hit-decimal-display.md) | 中文 + +## 问题 + +Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比率超过 99% 后,显示会隐藏后续提升;比率达到 99.5% 时,即使仍有未缓存输入或缓存写入,也会显示为 100%。 + +用户因此无法区分接近完整的缓存命中与真实满命中。 + +## 决策 + +`StatsLine` 继续从 `@deepseek-ai/dsh-token-meter` 所拥有的完整会话 `tokenUsage` 投影派生比率;该投影仍是未缓存输入、缓存读取、缓存写入与输出计数的唯一所有方([投影决策](../architecture/2026-07-29-projected-token-usage-and-request-context.md))。展示层只改变插入现有 `stats.cacheHit` locale 模板的文本。 + +| 真实比率 | 显示结果 | +|---|---| +| 没有计费输入 | 省略缓存命中分组 | +| 整数舍入结果低于 100% | 舍入后的整数 | +| 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 | +| 100% | `100%` | + +所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 使用 `bigint` 缩放并舍入整数 token 计数,从而避开浮点格式化限制,且不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。 + +## 归属与生命周期 + +token-meter 继续从完整持久会话日志折叠用量。标准投影值变化时,`StatsLine` 同步派生显示文本。本决策不引入设置、持久百分比、事件、协议字段、客户端状态或恢复路径。 + +实时更新、刷新回放与重连恢复都会还原同一组 `tokenUsage` 计数,并运行同一个显示函数。投影缺失时仍会省略全部 token 分组;输入分母为零时仍只省略缓存命中分组。 + +## 验证 + +组件测试固定了零分母、普通整数舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。 + +## 备选方案 + +**对所有比率继续使用整数舍入。** 不予采纳,因为它会隐藏 99% 以上的全部变化,并继续把部分非满命中显示为 100%。 + +**把高位区间向下截取到一位小数。** 不予采纳,因为 `99.95%`、`99.995%` 以及更接近满命中的比率都会坍缩为 `99.9%`,无法保留区分真实满命中所需的最少精度。 + +**限制精度并使用 `<100%` 等替代文案。** 不予采纳,因为精确累计计数能够产生所需的数值结果,而精度上限会让显示行为依赖任意的展示限制。 + +**所有比率都显示一位小数。** 不予采纳,因为低位区间的额外变化会增加无效抖动,并改变整数精度已经足够的既有显示。 + +**在 token-meter 中持久化显示百分比。** 不予采纳,因为投影已经携带精确计数,而展示精度属于 Web 统计行。第二个持久值会复制可派生状态,并扩大回放与协议职责。 + +## 后果 + +高缓存命中率会保持稳定的整数显示,直到整数舍入会错误地报告满命中;此时界面只展示维持区分所需的小数位。极接近满命中的非满比率可能因此产生较长的小数字符串,这是不设置任意精度上限或非数值回退所接受的代价。所有交付与恢复路径继续沿用既有持久投影生命周期。 diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index dcfabacf93..869aac2f14 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -25,6 +25,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/lifecycle-chrome', import.meta.url)) const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') @@ -45,7 +46,9 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () const sessionEvents: SessionEvent[] = [] beforeAll(async () => { - scaffold = await launchWebScaffold(MODE === 'record' ? {} : { replayFixture: FIXTURE, paceMs: REPLAY_PACE_MS }) + scaffold = await launchWebScaffold(MODE === 'record' + ? {} + : { replayFixture: FIXTURE, replayOverride: REPLAY_OVERRIDE, paceMs: REPLAY_PACE_MS }) scaffold.ctx.on('session/event', (_session, event: SessionEvent) => { sessionEvents.push(event) }) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -206,6 +209,7 @@ 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) // 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 @@ -271,7 +275,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.jsonl', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', + 'session.jsonl', 'replay.override.json', 'command-menu.expected.md', 'command-menu-fuzzy.expected.md', 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', ]) }) }) diff --git a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md index 9221d887b0..4d3fe1aa9f 100644 --- a/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md +++ b/apps/web/tests/snapshots/lifecycle-chrome/reloaded.expected.md @@ -37,6 +37,6 @@ - button "Select model, current DeepSeek-V4-Flash": - text: DeepSeek-V4-Flash - img -- button "6% of context used" +- button "8% of context used" - button "Send message" [disabled] -- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99.5% Input 10K tok · Output 21 tok diff --git a/apps/web/tests/snapshots/lifecycle-chrome/replay.override.json b/apps/web/tests/snapshots/lifecycle-chrome/replay.override.json new file mode 100644 index 0000000000..71317fe822 --- /dev/null +++ b/apps/web/tests/snapshots/lifecycle-chrome/replay.override.json @@ -0,0 +1,153 @@ +{ + "patches": [ + { + "at": 0, + "entry": { + "kind": "chunks", + "chunks": [ + { + "type": "block-start", + "index": 0, + "blockType": "reasoning" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": "The" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " user" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " wants" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " me" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " to" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " reply" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " with" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " a" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " single" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " word" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": "." + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " Let" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " me" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": " comply" + }, + { + "type": "reasoning-delta", + "index": 0, + "text": "." + }, + { + "type": "block-start", + "index": 1, + "blockType": "text" + }, + { + "type": "text-delta", + "index": 1, + "text": "L" + }, + { + "type": "text-delta", + "index": 1, + "text": "IGH" + }, + { + "type": "text-delta", + "index": 1, + "text": "TH" + }, + { + "type": "text-delta", + "index": 1, + "text": "O" + }, + { + "type": "text-delta", + "index": 1, + "text": "USE" + }, + { + "type": "block-end", + "index": 0, + "block": { + "type": "reasoning", + "text": "The user wants me to reply with a single word. Let me comply." + } + }, + { + "type": "block-end", + "index": 1, + "block": { + "type": "text", + "text": "LIGHTHOUSE" + } + }, + { + "type": "usage", + "usage": { + "inputTokens": 50, + "outputTokens": 21, + "cacheReadTokens": 9950, + "reasoningTokens": 15 + } + }, + { + "type": "finish", + "reason": { + "kind": "stop" + } + } + ] + } + } + ] +} diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e1d7e10227..18c6371734 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.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 packages/client/ui-conversation/README.md -README.md: 7793273e053142b232cf6e810cd915ddbdeb90b0 -README.zh.md: f45dfa6d72ecf5b49a12c9550d41dbcbc35f9be0 +README.md: 2e3b99fbd4fca6bbe7b12d8408edb30597f8259e +README.zh.md: 6f3aaa313c0f9372a6191e50dd34916fadffa8f3 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7793273e05..2e3b99fbd4 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ Image intake accepts paste and whole-page drop: the bar binds document-level dra The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `InputTriggerController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-input-trigger's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. -The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. +The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Every non-empty ratio starts with integer rounding. A non-full ratio adds decimal places only while the current precision would round to 100%, stopping at the minimum precision that remains below 100%; only a full cache hit displays 100%, and the precision has no fixed limit. The turn and step counts, the LLM and tool wall times, and the latency/throughput group all ride the whole-log `sessionStats` projection (host-folded from step boundaries, first-token chunks, tool pairs, and assembled messages), so paging and compaction cannot change any strip figure; an assembly without that unit falls back to the window fold over visible nodes, whose fields mirror the projection's. The strip averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them, and durable count, token, and context groups remain visible when compaction leaves no assistant node in the loaded window. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. `src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index f45dfa6d72..6f3aaa313c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `InputTriggerController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-input-trigger 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 -聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 +聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。所有非空比率都先按整数舍入。非满命中只有在当前精度会舍入成 100% 时才增加小数位,并在首次得到低于 100% 的结果时停止;只有完整缓存命中才显示 100%,且精度没有固定上限。轮次与步骤计数、LLM(大语言模型)与工具墙钟时间、以及延迟/吞吐分组都来自全日志的 `sessionStats` 投影(Host 端从步边界、首 token chunk、工具配对与已组装消息折算),因此分页与压缩都无法改变统计条的任何数字;未组合该单元的装配回退为对可见节点做窗口折算,其字段与投影一一对应。统计条把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久计数、token 与上下文分组仍保持可见。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 `src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 147d2b7c6c..022b71d890 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -102,15 +102,33 @@ export function formatDuration(ms: number): string { } /** - * Cache-hit share of prompt-side input over the whole durable log. + * Display-ready cache-hit share of prompt-side input over the whole durable log. * @param usage - the session's token-usage projection value. - * @returns rounded integer percent, or null when no input was billed. + * @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): number | null { - const denominator = billedInputTokens(usage) - return denominator === 0 - ? null - : Math.round(usage.cacheReadTokens / denominator * 100) +export function cacheHitPercent(usage: TokenUsageProjection): string | null { + const cacheReadTokens = BigInt(usage.cacheReadTokens) + const denominator = BigInt(usage.uncachedInputTokens) + + cacheReadTokens + + BigInt(usage.cacheWriteTokens) + if (denominator === 0n) return null + if (cacheReadTokens === denominator) return '100' + + let decimalPlaces = 0 + let decimalScale = 1n + while (true) { + const fullHit = 100n * decimalScale + const rounded = (2n * cacheReadTokens * fullHit + denominator) / (2n * denominator) + if (rounded < fullHit) { + if (decimalPlaces === 0) return rounded.toString() + const digits = rounded.toString().padStart(decimalPlaces + 1, '0') + return `${digits.slice(0, -decimalPlaces)}.${digits.slice(-decimalPlaces)}` + } + decimalPlaces += 1 + decimalScale *= 10n + } } /** diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index 01be6e0850..e0dbc6f723 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -191,6 +191,10 @@ describe('StatsLine', () => { return { useSession: bindSnapshotSelector(source), useProjection: projections(values), t: tEn } } + function tokenUsage(cacheReadTokens: number, uncachedInputTokens: number) { + return { uncachedInputTokens, outputTokens: 1, cacheReadTokens, cacheWriteTokens: 0 } + } + it('renders the grouped stats row and hides a brand-new empty session', () => { const { source } = makeSource({ nodes: [assistant(1, 1)] }) const view = render() @@ -205,19 +209,40 @@ describe('StatsLine', () => { expect(emptyView.container.textContent).toBe('') }) + it.each([ + { actual: '98.6%', tokenUsageValue: tokenUsage(986, 14), expected: 'Cache hit 99%' }, + { actual: '99.1%', tokenUsageValue: tokenUsage(991, 9), expected: 'Cache hit 99%' }, + { actual: '99.49%', tokenUsageValue: tokenUsage(9_949, 51), expected: 'Cache hit 99%' }, + { actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' }, + { actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' }, + { actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' }, + { actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' }, + { + actual: 'the closest non-full ratio available from safe integer cumulative counts', + tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1), + expected: 'Cache hit 99.99999999999999%', + }, + { 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) + }) + it('reveals the full line in a delayed hover tooltip only while the row is clipped', () => { vi.useFakeTimers() // jsdom lays nothing out; fake a row narrower than its content. vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(800) vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(400) const { source } = makeSource({ nodes: [assistant(1, 1)] }) - const view = render() + 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 90% | Input 100 tok · Output 5 tok') + .toBe('1 turns · 1 steps | Cache hit 99.95% | Input 10K tok · Output 1 tok') }) it('suppresses the tooltip while the row fits without truncation', () => { @@ -245,9 +270,9 @@ describe('StatsLine', () => { timing: { stepStartTime: 1_000, firstTokenTime: 1_800, completedTime: 4_800 }, } const { source } = makeSource({ nodes: [timed] }) - const view = render() + const view = render() expect(view.container.textContent) - .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 90%| 输入 100 tok · 输出 5 tok') + .toBe('1 轮 · 1 步| LLM 3.8s| 首 token 平均 0.8s · 20 tok/s| 缓存命中 99.95%| 输入 10K tok · 输出 1 tok') }) it('renders without ResizeObserver support', () => { From fa2ce12162f2bf8691ead92fd5c85212a9a041da Mon Sep 17 00:00:00 2001 From: pku-xht Date: Wed, 19 Aug 2026 14:41:04 +0800 Subject: [PATCH 02/34] review fix: avoid bigint cache-hit formatting --- ...9-high-cache-hit-decimal-display.i18n.yaml | 4 +- ...26-08-19-high-cache-hit-decimal-display.md | 4 +- ...08-19-high-cache-hit-decimal-display.zh.md | 4 +- .../src/client/chat/StatsLine.tsx | 64 ++++++++++++++----- .../tests/chat-stats.client.spec.tsx | 3 + 5 files changed, 56 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml index b95a27e700..114dcbfe23 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.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-19-high-cache-hit-decimal-display.md -2026-08-19-high-cache-hit-decimal-display.md: 29e0ebc11ae690b918c7e9364f29635de1e50f2a -2026-08-19-high-cache-hit-decimal-display.zh.md: 28cd99a4c321cc4da0ce03142e2fbf438539b0ff +2026-08-19-high-cache-hit-decimal-display.md: 952d838fdf330175915e13ef767fd80d145506b2 +2026-08-19-high-cache-hit-decimal-display.zh.md: 4a72becd608e756507f3d5a6fd051b4b2b437964 diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md index 29e0ebc11a..952d838fdf 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.md @@ -21,7 +21,7 @@ Users therefore could not distinguish a nearly complete cache hit from a true fu | Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% | | 100% | `100%` | -Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` scales and rounds the integer token counts with `bigint`, which avoids floating-point formatting limits and imposes no precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip. +Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` uses exact small-factor comparisons over the safe-integer token counts, then scales the near-full gap only while the intermediate remains within that range. This avoids floating-point tie errors without imposing a precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip. ## Ownership and lifecycle @@ -31,7 +31,7 @@ Live updates, reload replay, and reconnect recovery all restore the same `tokenU ## Verification -The component spec pins the zero denominator, ordinary integer rounding, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call. +The component spec pins the zero denominator, ordinary integer rounding, half-step rounding at several decimal precisions, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md index 28cd99a4c3..4a72becd60 100644 --- a/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-19-high-cache-hit-decimal-display.zh.md @@ -21,7 +21,7 @@ Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比 | 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 | | 100% | `100%` | -所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 使用 `bigint` 缩放并舍入整数 token 计数,从而避开浮点格式化限制,且不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。 +所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 对安全整数 token 计数执行精确的小因子比较,并且只在中间值仍处于该范围内时缩放接近满命中的差值。该算法既避开浮点临界值误差,也不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。 ## 归属与生命周期 @@ -31,7 +31,7 @@ token-meter 继续从完整持久会话日志折叠用量。标准投影值变 ## 验证 -组件测试固定了零分母、普通整数舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。 +组件测试固定了零分母、普通整数舍入、多个小数精度上的半步舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。 ## 备选方案 diff --git a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 022b71d890..2d9d14483b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -101,6 +101,26 @@ export function formatDuration(ms: number): string { return `${Math.floor(whole / 60)}m${whole % 60}s` } +/** Round a cache-read ratio to an integer percentage, with positive ties rounded up. */ +function roundedIntegerPercent(cacheReadTokens: number, denominator: number): number { + const denominatorQuotient = Math.floor(denominator / 200) + const denominatorRemainder = denominator % 200 + let lower = 0 + let upper = 100 + while (lower < upper) { + const candidate = Math.floor((lower + upper + 1) / 2) + const factor = candidate * 2 - 1 + const threshold = factor * denominatorQuotient + + Math.ceil(factor * denominatorRemainder / 200) + if (cacheReadTokens >= threshold) { + lower = candidate + } else { + upper = candidate - 1 + } + } + return lower +} + /** * Display-ready cache-hit share of prompt-side input over the whole durable log. * @param usage - the session's token-usage projection value. @@ -109,26 +129,36 @@ export function formatDuration(ms: number): string { * 100, and no billed input returns null. */ export function cacheHitPercent(usage: TokenUsageProjection): string | null { - const cacheReadTokens = BigInt(usage.cacheReadTokens) - const denominator = BigInt(usage.uncachedInputTokens) - + cacheReadTokens - + BigInt(usage.cacheWriteTokens) - if (denominator === 0n) return null - if (cacheReadTokens === denominator) return '100' + const denominator = billedInputTokens(usage) + if (denominator === 0) return null + const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens + if (missedInputTokens === 0) return '100' - let decimalPlaces = 0 - let decimalScale = 1n - while (true) { - const fullHit = 100n * decimalScale - const rounded = (2n * cacheReadTokens * fullHit + denominator) / (2n * denominator) - if (rounded < fullHit) { - if (decimalPlaces === 0) return rounded.toString() - const digits = rounded.toString().padStart(decimalPlaces + 1, '0') - return `${digits.slice(0, -decimalPlaces)}.${digits.slice(-decimalPlaces)}` - } + const integerPercent = roundedIntegerPercent(usage.cacheReadTokens, denominator) + if (integerPercent < 100) return String(integerPercent) + + // At the first distinguishing precision, the rounded result is 100 minus + // one to five units in the final decimal place. Scale only while the next + // multiplication remains at or below the denominator, then derive that + // final digit through exact small-factor comparisons. + let decimalPlaces = 1 + let scaledDoubleGap = missedInputTokens * 200 + const denominatorTens = Math.floor(denominator / 10) + while (scaledDoubleGap <= denominatorTens) { + scaledDoubleGap *= 10 decimalPlaces += 1 - decimalScale *= 10n } + const denominatorOnes = denominator % 10 + let roundedLoss = 5 + for (let loss = 1; loss < 5; loss += 1) { + const factor = loss * 2 + 1 + const threshold = factor * denominatorTens + Math.floor(factor * denominatorOnes / 10) + if (scaledDoubleGap <= threshold) { + roundedLoss = loss + break + } + } + return `99.${'9'.repeat(decimalPlaces - 1)}${10 - roundedLoss}` } /** diff --git a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx index e0dbc6f723..be55ee0579 100644 --- a/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.client.spec.tsx @@ -216,7 +216,10 @@ describe('StatsLine', () => { { actual: '99.5%', tokenUsageValue: tokenUsage(995, 5), expected: 'Cache hit 99.5%' }, { actual: '99.94%', tokenUsageValue: tokenUsage(9_994, 6), expected: 'Cache hit 99.9%' }, { actual: '99.95%', tokenUsageValue: tokenUsage(9_995, 5), expected: 'Cache hit 99.95%' }, + { actual: '99.955%', tokenUsageValue: tokenUsage(19_991, 9), expected: 'Cache hit 99.96%' }, + { actual: '99.985%', tokenUsageValue: tokenUsage(19_997, 3), expected: 'Cache hit 99.99%' }, { actual: '99.995%', tokenUsageValue: tokenUsage(19_999, 1), expected: 'Cache hit 99.995%' }, + { actual: '99.9975%', tokenUsageValue: tokenUsage(39_999, 1), expected: 'Cache hit 99.998%' }, { actual: 'the closest non-full ratio available from safe integer cumulative counts', tokenUsageValue: tokenUsage(Number.MAX_SAFE_INTEGER - 1, 1), From 000ab970f3a82a799701d3deb47464c1de104328 Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Wed, 19 Aug 2026 19:58:14 +0800 Subject: [PATCH 03/34] feat(web): size markdown tables by column count, widen wide ones past the column Markdown tables were always rendered at natural width, so anything wider than the 748px message column could only be read through horizontal scrolling. Following the deepsuite chat TableWrapper treatment: tables under four columns (and any table inside a blockquote) now fill the column and wrap cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width behind the wrapper's horizontal scroll and carry the stable md-table-wide hook. The chat transcript widens hooked tables past the message column with a pure-CSS container-query breakout (100cqw against ChatView's scroll container standing in for deepsuite's JS-measured list width), keeping the table content aligned with the message column and clamping to neutral when the transcript is narrower than the column. --- ...-19-web-markdown-wide-table-view.i18n.yaml | 6 + ...2026-08-19-web-markdown-wide-table-view.md | 37 ++ ...6-08-19-web-markdown-wide-table-view.zh.md | 37 ++ apps/web/tests/markdown-wide-table.e2e.ts | 454 ++++++++++++++++++ .../markdown-wide-table/geometry.expected.md | 18 + apps/web/tsconfig.json | 1 + .../client/chat/AssistantMarkdown.module.css | 22 + .../src/client/chat/ChatView.module.css | 5 + .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/MarkdownText.module.css | 19 + .../ui-primitives/src/markdown/render.tsx | 17 +- .../markdown-dom/math-edge-cases.settled.txt | 2 +- .../math-edge-cases.streaming.txt | 2 +- .../table-header-only.settled.txt | 2 +- .../table-header-only.streaming.txt | 2 +- .../table-wide-and-blockquote.settled.txt | 46 ++ .../table-wide-and-blockquote.streaming.txt | 46 ++ .../table-with-alignment.settled.txt | 2 +- .../table-with-alignment.streaming.txt | 2 +- .../tests/markdown-dom-parity.client.spec.tsx | 11 + .../markdown-render-units.client.spec.tsx | 8 + tsconfig.host.json | 1 + 24 files changed, 736 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md create mode 100644 .agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md create mode 100644 apps/web/tests/markdown-wide-table.e2e.ts create mode 100644 apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt create mode 100644 packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml new file mode 100644 index 0000000000..19561bb6f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.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-08-19-web-markdown-wide-table-view.md +2026-08-19-web-markdown-wide-table-view.md: 847b3f055edd29eeb0b6f3f8bec5b97252aca4f4 +2026-08-19-web-markdown-wide-table-view.zh.md: d2fcc18f883f71a3fdbfbab6476c87b1c9840b21 diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md new file mode 100644 index 0000000000..847b3f055e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md @@ -0,0 +1,37 @@ +# Agent Note: Web markdown tables fill the column by count, wide ones break out + +Status: implemented + +English | [中文](2026-08-19-web-markdown-wide-table-view.zh.md) + +## Problem + +`MarkdownText` rendered every GFM table at its natural width (`.tableScroll table { width: max-content; max-width: max-content }`, `packages/client/ui-primitives/src/markdown/MarkdownText.module.css`), so any table wider than the 748px message column could only be read through horizontal scrolling. A three-column table whose cells could comfortably wrap still forced a scroll, and a genuinely wide table could never use more than the message column even when the transcript around it had hundreds of spare pixels. Issue #1761 (with external feedback dsh-external/issues#520) asks for wrap-first adaptation and a wider view for tables that need it. The deepsuite chat product solved the same problem CSS-first; per review direction this change mirrors that solution instead of the interactive expand-dialog approach first drafted here. + +## Decision + +**Column count picks the sizing arm, statically, in the renderer.** `renderTable` reads the parsed column count (the `align` array, header-row fallback): tables under four columns — and any table inside a blockquote (`inBlockquote` render-context flag) — get the module's `tableFill` class, `table { width: 100%; max-width: none }`, filling the column with cells wrapping down to their existing `min-width: 100px` floor. Four-or-more-column tables keep the natural-width rules and instead carry the stable global hook class `md-table-wide` (the `md-code-block` precedent), so a hosting layout can widen them. This is deepsuite chat's discriminator (`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` plus its blockquote exemption) computed in the renderer, which already knows the column count. No measurement, observers, or interaction state anywhere. + +**The chat transcript widens hooked tables with container-query CSS.** ChatView's `.scroll` declares `container-type: inline-size`, and `AssistantMarkdown.module.css` gives `.body :global(.md-table-wide)` the breakout: `--dsh-table-spare` is the per-side spare width `max(0px, (100cqw - --dsh-chat-content-width) / 2)`, `--dsh-table-lead` adds the wrapper's own indent (`min(--dsh-chat-content-width, 100cqw) - 100%`), and width/negative-margin/lead-padding combine so the wrapper's scroll area spans the full transcript while the table content keeps starting at the message column's left edge. `100cqw` is the CSS stand-in for deepsuite chat's JS-measured `--dsl-virtual-list-width`; the `max(0px, …)` clamp replaces its below-SM JS gate, degrading continuously to the plain in-column scroll when the transcript is narrower than the message column. Scoping the rule under `AssistantMarkdown .body` keeps tool cards, compaction rows, and every other `MarkdownText` surface at plain in-column behavior. + +**The parity fixtures change deliberately.** The table-containing markdown-dom fixtures pin the discriminator: `tableScroll tableFill` for narrow and blockquote tables, `tableScroll md-table-wide` for wide ones, and a new `table-wide-and-blockquote` corpus document pins both arms of the blockquote exemption. + +## Alternatives considered + +**Overflow-measured chrome: a ResizeObserver-gated expand entry opening the table in a `Modal` wide view.** Implemented first, then rejected on review direction in favor of deepsuite chat parity: the CSS solution needs no per-table observers, no dialog state that the streaming finalize swap would drop, no label plumbing through the cordis-free package, and gives the wide view permanently instead of behind an interaction. + +**`:has()`-based column counting in CSS, as deepsuite chat does.** Rejected: their wrapper is generic while this renderer already walks the table node, so the count is available statically; a class is cheaper than a `:has()` selector re-evaluated on DOM changes and pins the decision in the DOM for fixtures. + +**Breaking out to the viewport rather than the transcript.** Rejected: the conversation column pins `overflow-x: hidden` (the one-axis contract in `apps/web/tests/conversation-column-overflow.e2e.ts`), and anything wider than the transcript box would clip; the transcript width is exactly the space the layout actually has. + +## Consequences + +An ordinary wide table reads in place with wrapped cells; a many-column table keeps its readable natural width, spans the whole transcript where the layout has spare width, and scrolls for the remainder — with no interaction required and nothing to restore. Chromium keyboard-focuses scrollable containers by default, so the wrapper stays keyboard-scrollable without added attributes (a `:focus-visible` ring marks it). Two knowledge edges: `container-type: inline-size` on ChatView's `.scroll` makes it the nearest query container for anything inside the transcript that later uses container units, and sub-four-column tables now always stretch to the full column width (deepsuite chat behavior) rather than shrink-wrapping short content. + +## Testing + +The markdown-dom parity fixtures pin the wrapper classes per arm, including the new `table-wide-and-blockquote` document; `markdown-render-units.client.spec.tsx` covers the hand-built rowless/align-less fallback. `apps/web/tests/markdown-wide-table.e2e.ts` seeds a closed turn with three tables (three-column fill, twelve-column wide, long-token/CJK long-cell) and, in real Chromium, pins the relations golden across viewport stops — fill and long-cell tables fill the column with no residual scroll at every stop and grow taller as the column narrows, the wide table always scrolls, breaks out past the message column exactly at the stops where the transcript is wider than it, keeps its content left-aligned with the fill table's under the breakout, and clamps to neutral at the narrow stop — plus arrow-key scrolling of the focused wrapper, a zoom arm, and a deviceScaleFactor-2 arm that must report the same relations. + +## Related + +- [Web markdown incremental AST renderer](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) — the renderer and DOM-parity contract this change extends. diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md new file mode 100644 index 0000000000..d2fcc18f88 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md @@ -0,0 +1,37 @@ +# Agent Note:Web markdown 表格按列数填充消息列,宽表突破列宽 + +Status: implemented + +[English](2026-08-19-web-markdown-wide-table-view.md) | 中文 + +## 问题 + +`MarkdownText` 把每个 GFM 表格都按自然宽度渲染(`.tableScroll table { width: max-content; max-width: max-content }`,`packages/client/ui-primitives/src/markdown/MarkdownText.module.css`),于是任何比 748px 消息列更宽的表格都只能靠横向滚动阅读。单元格本可以舒适换行的三列表格也被迫滚动;而真正宽的表格即便转录区周围有几百像素的空余,也永远只能用消息列那么宽。Issue #1761(含外部反馈 dsh-external/issues#520)要求先换行适应,并为需要的表格提供更宽的视图。deepsuite chat 产品已用 CSS 优先的方式解决了同一问题;按评审方向,本变更对齐该方案,替换此前起草的交互式展开对话框方案。 + +## 决定 + +**列数在渲染器里静态决定尺寸分支。**`renderTable` 读取解析出的列数(`align` 数组,缺省回退表头行):不足四列的表格——以及 blockquote 内的任何表格(渲染上下文的 `inBlockquote` 标志)——获得模块的 `tableFill` 类,`table { width: 100%; max-width: none }`,填满消息列,单元格按既有 `min-width: 100px` 下限换行。四列及以上的表格保持自然宽度规则,改挂稳定的全局钩子类 `md-table-wide`(沿用 `md-code-block` 先例),供宿主布局加宽。这正是 deepsuite chat 的判别式(`.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))` 及其 blockquote 豁免),只是移到已经掌握列数的渲染器里计算。全程没有测量、observer 或交互状态。 + +**聊天转录区用容器查询 CSS 加宽挂钩表格。**ChatView 的 `.scroll` 声明 `container-type: inline-size`,`AssistantMarkdown.module.css` 给 `.body :global(.md-table-wide)` 定义突破:`--dsh-table-spare` 是单侧空余宽度 `max(0px, (100cqw - --dsh-chat-content-width) / 2)`,`--dsh-table-lead` 再加上包裹层自身的缩进(`min(--dsh-chat-content-width, 100cqw) - 100%`),宽度/负 margin/前导 padding 组合起来,让包裹层的滚动区横跨整个转录区,而表格内容仍从消息列左缘起排。`100cqw` 是 deepsuite chat 用 JS 测量的 `--dsl-virtual-list-width` 的 CSS 等价物;`max(0px, …)` 钳制取代其 below-SM 的 JS 开关,转录区窄于消息列时连续退化为普通列内滚动。规则限定在 `AssistantMarkdown .body` 之下,工具卡片、压缩行等其他 `MarkdownText` 表面保持普通列内行为。 + +**一致性 fixture 的变化是有意的。**含表格的 markdown-dom fixture pin 住判别结果:窄表与 blockquote 表为 `tableScroll tableFill`,宽表为 `tableScroll md-table-wide`;新增的 `table-wide-and-blockquote` 语料文档同时 pin 住 blockquote 豁免的两个分支。 + +## 曾考虑的替代方案 + +**实测溢出的交互件:ResizeObserver 门控的展开入口,用 `Modal` 打开宽视图。**先行实现,后按评审方向否决、改为对齐 deepsuite chat:CSS 方案不需要逐表 observer,没有会被流式定稿替换丢弃的对话框状态,不用穿过 cordis-free 包的文案管道,且宽视图是常驻的而非藏在交互后面。 + +**像 deepsuite chat 那样用 `:has()` 在 CSS 里数列。**否决:它们的包裹层是通用组件,而本渲染器本来就在遍历表格节点,列数是静态可得的;类名比随 DOM 变化反复求值的 `:has()` 选择器更便宜,还把决定固化进 DOM 供 fixture pin 住。 + +**突破到视口宽而不是转录区宽。**否决:会话列 pin 死了 `overflow-x: hidden`(`apps/web/tests/conversation-column-overflow.e2e.ts` 的单轴契约),超出转录区盒子的部分会被裁剪;转录区宽度正是布局实际拥有的空间。 + +## 后果 + +普通宽表原地换行阅读;多列表格保持可读的自然宽度,在布局有空余处横跨整个转录区,剩余部分滚动——无需任何交互,也没有状态要恢复。Chromium 默认让可滚动容器可键盘聚焦,因此包裹层无需附加属性即可键盘滚动(`:focus-visible` 有焦点圈)。两个需要知道的点:ChatView `.scroll` 上的 `container-type: inline-size` 使它成为转录区内后续使用容器单位的最近查询容器;不足四列的表格现在总是拉伸到整列宽(deepsuite chat 行为),而不是按内容收缩。 + +## 测试 + +markdown-dom 一致性 fixture 按分支 pin 住包裹层类名,含新增的 `table-wide-and-blockquote` 文档;`markdown-render-units.client.spec.tsx` 覆盖手工树的无行无 align 兜底。`apps/web/tests/markdown-wide-table.e2e.ts` seed 一个含三个表格的已关闭轮次(三列填充表、十二列宽表、长 token/中文长单元格表),在真实 Chromium 中跨视口档 pin 关系 golden——填充表与长单元格表在每一档都填满消息列、无残余滚动、随列变窄而变高;宽表始终滚动、恰好在转录区宽于消息列的档位突破列宽、突破时内容与填充表左对齐、窄档钳制为中性——外加聚焦包裹层的方向键滚动、缩放分支、以及必须报告相同关系的 deviceScaleFactor-2 分支。 + +## 相关 + +- [Web markdown 增量 AST 渲染器](../architecture/2026-08-06-web-markdown-incremental-ast-renderer.md) —— 本变更所扩展的渲染器与 DOM 一致性契约。 diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts new file mode 100644 index 0000000000..da2c371085 --- /dev/null +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -0,0 +1,454 @@ +// Web e2e scenario: markdown tables in the message column, deepsuite-chat +// parity. Tables under four columns (and long-cell tables) fill the 748px +// message column and wrap; four-or-more-column tables keep their natural +// width, scroll horizontally inside their wrapper, and — through the +// renderer's `md-table-wide` hook plus AssistantMarkdown's container-query +// breakout — span the whole transcript width instead of clipping at the +// message column, with the table content still starting at the message +// column's left edge. When the transcript is narrower than the message +// column the breakout clamps to neutral and the plain in-column scroll +// remains. +// +// Only a real engine lays out CSS tables and resolves container-query +// units, so the fill/scroll/breakout relations, the lead-padding alignment, +// arrow-key scrolling, and the zoom/DPR arms are all measured in Chromium +// across viewport stops. The golden records relations and booleans, never +// pixels: absolute widths document the platform, not the behavior. +// +// Zero model calls: the transcript is a closed turn assembled through the +// Session API and seeded cold; a stray stream would fail loud with +// NO_ADAPTER. +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { + SESSION_FORMAT_VERSION, + Session, + SessionId, +} from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + watchConsole, + webSnapshotMode, + type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/markdown-wide-table', import.meta.url)) +const GEOMETRY_EXPECTED = fileURLToPath( + new URL('./snapshots/markdown-wide-table/geometry.expected.md', import.meta.url), +) +const MODE = webSnapshotMode() +const SEED_ID = 'markdown-wide-table-web-e2e' +/** Painted into the final paragraph; the open barrier waits for it. */ +const TAIL_MARKER = 'MWT_TABLES_DONE' + +/** + * First-header-cell markers identify each table without depending on CSS + * module hashes or DOM order. + */ +const FILL_MARKER = 'MWT_FILL_C1' +const WIDE_MARKER = 'MWT_WIDE_C01' +const LONG_CELL_MARKER = 'MWT_LONGCELL_F1' +const MARKERS = [FILL_MARKER, WIDE_MARKER, LONG_CELL_MARKER] +/** Golden-facing names, in {@link MARKERS} order. */ +const TABLE_NAMES = ['fill', 'wide', 'long-cell'] + +/** + * Viewport sweep. The wide stops leave the transcript far wider than the + * 748px message column, so the breakout relation holds with a fat margin on + * every platform; the narrow stop drops the transcript below the message + * column, which must clamp the breakout to neutral. The sidebar is collapsed + * for the whole sweep (see beforeAll), so the transcript width follows the + * viewport identically on overlay- and classic-scrollbar platforms. + */ +const WIDTHS = [1680, 1100, 640] + +/** A sentence long enough that three of them cannot sit unwrapped in the 748px column. */ +const SENTENCE = 'This cell carries one full sentence so the unwrapped table is far wider than the message column.' +/** Unbroken path-like token (no scheme, so GFM does not autolink it and no anchor joins the tab order). */ +const LONG_TOKEN = 'workspace/deepseek-harness/packages/client/ui-primitives/src/markdown/render.tsx/'.repeat(3) +const CJK_SENTENCE = '这个单元格包含一段较长的中文说明,用来验证长内容在窄列宽下按最小可读宽度换行而不是把列压缩到无法阅读。' + +/** The assistant markdown: one 3-column fill, one 12-column wide, one long-cell table. */ +function tablesMarkdown(): string { + const wideHeader = [WIDE_MARKER, ...Array.from({ length: 11 }, (_, i) => `C${String(i + 2).padStart(2, '0')}`)] + const wideRow = (row: number): string[] => + Array.from({ length: 12 }, (_, i) => `v${String(row)}${String(i + 1).padStart(2, '0')}`) + return [ + 'Three markdown tables exercise the wide-table layout rules.', + '', + `| ${FILL_MARKER} | Current approach | Proposed approach |`, + '| --- | --- | --- |', + `| Rendering | ${SENTENCE} | ${SENTENCE} |`, + `| Memory | ${SENTENCE} | ${SENTENCE} |`, + '', + `| ${wideHeader.join(' | ')} |`, + `|${' --- |'.repeat(12)}`, + `| ${wideRow(1).join(' | ')} |`, + `| ${wideRow(2).join(' | ')} |`, + '', + `| ${LONG_CELL_MARKER} | Value |`, + '| --- | --- |', + `| path | ${LONG_TOKEN} |`, + `| 说明 | ${CJK_SENTENCE} |`, + '', + TAIL_MARKER, + ].join('\n') +} + +/** Build one closed, invariant-checked session fixture carrying the three tables. */ +function wideTableFixture(): string { + const session = Session.create(SessionId('markdown-wide-table-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Show the wide-table layout scenarios.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Markdown wide tables', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: tablesMarkdown() }], + source: { kind: 'model', provider: 'fixture', model: 'fixture' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + const header = { + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + } + return [ + JSON.stringify(header), + // Spaced event times, as the sibling markdown fixtures pin them. + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +/** One table's layout relations at the current viewport. */ +interface TableReading { + /** Identifying first-header-cell marker. */ + marker: string + /** `scrollWidth - clientWidth` of the wrapper: the residual horizontal scroll. */ + overflow: number + /** Wrapper content width. */ + clientWidth: number + /** Rendered wrapper height; wrapping shows up as growth when the column narrows. */ + height: number + /** Renderer marked the table with the `md-table-wide` breakout hook. */ + wideHook: boolean + /** Resolved lead padding (the breakout's alignment compensation). */ + paddingLeft: number + /** The table's own left x, for the content-alignment relation. */ + tableLeft: number +} + +/** Read all three tables' relations in one pass. */ +function readTables(page: Page): Promise { + return page.evaluate((markers) => { + const wrappers = [...document.querySelectorAll('[class*="tableScroll"]')] + return markers.map((marker) => { + const wrapper = wrappers.find(candidate => candidate.textContent?.includes(marker) ?? false) + if (wrapper === undefined) throw new Error(`table wrapper ${marker} not rendered`) + const table = wrapper.querySelector('table') + if (table === null) throw new Error(`table ${marker} not rendered`) + return { + marker, + overflow: wrapper.scrollWidth - wrapper.clientWidth, + clientWidth: wrapper.clientWidth, + height: wrapper.getBoundingClientRect().height, + wideHook: wrapper.classList.contains('md-table-wide'), + paddingLeft: Number.parseFloat(getComputedStyle(wrapper).paddingLeft), + tableLeft: table.getBoundingClientRect().left, + } + }) + }, MARKERS) +} + +/** A sweep stop: the three tables' readings at one viewport width. */ +interface TableStop { + width: number + tables: TableReading[] +} + +/** + * Close the details pane so the transcript spans the viewport. Open, it pins + * the transcript to exactly the message column and every breakout relation + * would go vacuous. + * @param target - the page whose pane to close. + */ +async function closeDetailsPane(target: Page): Promise { + await target.getByRole('button', { name: 'Close details', exact: true }).waitFor({ timeout: 10_000 }) + await target.evaluate(() => { + document.querySelector('button[aria-label="Close details"]')?.click() + }) + // Closed details resolve to zero width but never unmount (ui-layout + // columns contract), so the settled signal is the frame's collapse marker, + // not the button's detachment. + await target.waitForSelector('[data-details-collapsed]', { timeout: 5_000 }) +} + +/** + * Render the golden body: relations only. `fills` is the wrap-first claim + * (the wrapper has no residual horizontal scroll), `scrolls` the many-column + * fallback, and `breaks out` whether the wide wrapper spans past the message + * column (compared against the fill table, which by construction is exactly + * the message column's width). + * @param stops - the measured stops, in sweep order. + * @param wrapTighter - per table name, whether the block grew taller at the + * narrowest stop than at the widest (the proof wrapping engaged). + * @returns the golden body, without a trailing newline. + */ +function renderGeometry(stops: TableStop[], wrapTighter: Map): string { + return [ + '# Markdown wide-table relations', + '', + '| viewport | table | fills the column | scrolls | breaks out past the column |', + '| --- | --- | --- | --- | --- |', + ...stops.flatMap((stop) => { + const columnWidth = stop.tables[0]!.clientWidth + return stop.tables.map((table, index) => + `| ${String(stop.width)}px | ${TABLE_NAMES[index]} | ${String(table.overflow <= 1)} ` + + `| ${String(table.overflow > 1)} | ${String(table.clientWidth > columnWidth + 8)} |`, + ) + }), + '', + 'Wrap-first engagement (taller at the narrowest stop than at the widest):', + '', + ...[...wrapTighter.entries()].map(([name, tighter]) => `- ${name}: ${String(tighter)}`), + ].join('\n') +} + +describe('web e2e: markdown tables fill the column, wide ones break out and scroll', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, wideTableFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await page.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) + // Collapse the sidebar and close the details pane for the whole sweep: + // classic-scrollbar platforms (Linux CI) lose ~15px of layout width, + // which shifts how much of a narrow viewport the panes leave the + // transcript and lands the narrow stop's readings far from the macOS + // ones — and the details pane alone pins the transcript to exactly the + // message column, which would make every breakout relation vacuous. + // With both out of the equation the transcript follows the viewport + // identically on every platform, which is what keeps one committed + // golden true for all lanes. + await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + // JS click: after the transcript scrolled to its tail, the pane's close + // button can sit under the sticky header where a pointer click is + // intercepted; the pane itself is scaffolding, not the behavior under + // test, so actionability adds nothing here. + await closeDetailsPane(page) + }, 180_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + /** + * Resize to a viewport and read the tables once layout settles (the frame + * eases its column tracks, so a read straight after a resize can catch a + * mid-transition width). + * @param width - viewport width to settle at. + * @returns the three tables' readings at that width. + */ + const settleAt = async (width: number): Promise => { + await page.setViewportSize({ width, height: 900 }) + // The wide wrapper follows the transcript width (the fill wrapper caps + // at the message column and would report "settled" mid-transition). + let previousWidth = -1 + await expect.poll(async () => { + const current = (await readTables(page))[1]!.clientWidth + const settled = current === previousWidth + previousWidth = current + return settled + }, { timeout: 10_000 }).toBe(true) + return readTables(page) + } + + /** Sweep once; every assertion reads the same measurement. */ + let swept: Promise | undefined + const sweep = (): Promise => { + swept ??= (async () => { + const stops: TableStop[] = [] + for (const width of WIDTHS) stops.push({ width, tables: await settleAt(width) }) + return stops + })() + return swept + } + + it('fills narrow tables, scrolls wide ones, and breaks them out where the transcript is wider', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table')) + const stops = await sweep() + for (const stop of stops) { + const [fill, wide, longCell] = stop.tables + const columnWidth = fill!.clientWidth + // #520: an ordinary wide table fills the message column and wraps… + expect(fill!.overflow, `fill viewport ${String(stop.width)}`).toBeLessThanOrEqual(1) + // …a long unbroken token and long CJK prose wrap instead of forcing a scroll… + expect(longCell!.overflow, `long-cell viewport ${String(stop.width)}`).toBeLessThanOrEqual(1) + // …and a many-column table keeps its natural width behind the scroll fallback. + expect(wide!.overflow, `wide viewport ${String(stop.width)}`).toBeGreaterThan(1) + // The hook is column-count static, present at every stop. + expect(wide!.wideHook).toBe(true) + expect(fill!.wideHook).toBe(false) + expect(longCell!.wideHook).toBe(false) + if (stop.width > 748) { + // Breakout: the wide wrapper spans past the message column, and its + // lead padding keeps the table content starting at the message + // column's left edge (compared to the fill table's content). + expect(wide!.clientWidth, `wide breakout at ${String(stop.width)}`).toBeGreaterThan(columnWidth + 8) + expect(wide!.paddingLeft, `lead at ${String(stop.width)}`).toBeGreaterThan(0) + expect(Math.abs(wide!.tableLeft - fill!.tableLeft), `alignment at ${String(stop.width)}`).toBeLessThan(1.5) + } else { + // Below the message column there is no spare width: the breakout + // clamps to neutral and the wrapper stays the column's width. + expect(Math.abs(wide!.clientWidth - columnWidth), `neutral at ${String(stop.width)}`).toBeLessThan(1.5) + expect(wide!.paddingLeft, `no lead at ${String(stop.width)}`).toBeLessThan(1.5) + } + } + // Wrap-first engaged for real: the filling tables grow taller as the + // column narrows (the wide table only scrolls, so it is exempt). + const widest = stops[0]! + const narrowest = stops[stops.length - 1]! + expect(narrowest.tables[0]!.height).toBeGreaterThan(widest.tables[0]!.height) + expect(narrowest.tables[2]!.height).toBeGreaterThan(widest.tables[2]!.height) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('keeps the wide table keyboard-scrollable', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-keyboard')) + await sweep() + await settleAt(1680) + const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER }) + // Chromium makes scrollable containers keyboard-focusable by default; + // arrow keys then scroll the focused wrapper. + await wide.focus() + await page.keyboard.press('ArrowRight') + await page.keyboard.press('ArrowRight') + await expect.poll(() => wide.evaluate(element => element.scrollLeft), { timeout: 5_000 }) + .toBeGreaterThan(0) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('keeps the fill/scroll relations under page zoom', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom')) + await sweep() + await settleAt(1100) + try { + await page.evaluate(() => { document.documentElement.style.zoom = '1.25' }) + await expect.poll(async () => { + const [fill, wide, longCell] = await readTables(page) + return fill!.overflow <= 1 && longCell!.overflow <= 1 && wide!.overflow > 1 + }, { timeout: 10_000 }).toBe(true) + } finally { + await page.evaluate(() => { document.documentElement.style.zoom = '' }) + } + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('reports the same relations on a high-DPI page', async () => { + const hidpiPage = await browser.newPage({ + viewport: { width: 1100, height: 900 }, + deviceScaleFactor: 2, + locale: 'en-US', + }) + const hidpiTripwire = watchConsole(hidpiPage) + try { + onTestFailed(() => saveFailureShot(hidpiPage, 'web-e2e-markdown-wide-table-hidpi')) + await hidpiPage.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await hidpiPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const groupRow = hidpiPage.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = hidpiPage.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await hidpiPage.getByText(TAIL_MARKER, { exact: true }).waitFor({ timeout: 15_000 }) + await hidpiPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click() + await closeDetailsPane(hidpiPage) + // The pane collapses ease over the layout transition: compare only a + // settled reading (two consecutive equal wide-wrapper widths). + let readings: TableReading[] = [] + let previousWide = -1 + await expect.poll(async () => { + readings = await readTables(hidpiPage) + const settled = readings[1]!.clientWidth === previousWide + previousWide = readings[1]!.clientWidth + return settled + }, { timeout: 10_000 }).toBe(true) + const baseline = (await sweep()).find(stop => stop.width === 1100)! + const relations = (tables: TableReading[]) => tables.map(table => ({ + marker: table.marker, + fills: table.overflow <= 1, + breaksOut: table.clientWidth > tables[0]!.clientWidth + 8, + })) + expect(relations(readings)).toEqual(relations(baseline.tables)) + expect(hidpiTripwire.pageErrors).toEqual([]) + } finally { + await hidpiPage.close() + } + }, 120_000) + + it('matches the committed geometry golden', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-golden')) + const stops = await sweep() + const widest = stops[0]! + const narrowest = stops[stops.length - 1]! + const wrapTighter = new Map([ + ['fill', narrowest.tables[0]!.height > widest.tables[0]!.height], + ['long-cell', narrowest.tables[2]!.height > widest.tables[2]!.height], + ]) + await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(stops, wrapTighter), MODE) + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + + it('commits exactly the fixtures it reads', async () => { + // No model calls, so no replay log: the golden is the whole inventory. + await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md']) + }) + + it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => { + expect(tripwire.warnings).toEqual([]) + expect(tripwire.pageErrors).toEqual([]) + }) +}) diff --git a/apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md b/apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md new file mode 100644 index 0000000000..0e195a6bba --- /dev/null +++ b/apps/web/tests/snapshots/markdown-wide-table/geometry.expected.md @@ -0,0 +1,18 @@ +# Markdown wide-table relations + +| viewport | table | fills the column | scrolls | breaks out past the column | +| --- | --- | --- | --- | --- | +| 1680px | fill | true | false | false | +| 1680px | wide | false | true | true | +| 1680px | long-cell | true | false | false | +| 1100px | fill | true | false | false | +| 1100px | wide | false | true | true | +| 1100px | long-cell | true | false | false | +| 640px | fill | true | false | false | +| 640px | wide | false | true | false | +| 640px | long-cell | true | false | false | + +Wrap-first engagement (taller at the narrowest stop than at the widest): + +- fill: true +- long-cell: true diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 111aefe63c..38a0438ef9 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,6 +64,7 @@ "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", "tests/reference-composer.e2e.ts", + "tests/markdown-wide-table.e2e.ts", "tests/math-rendering.e2e.ts", "tests/markdown-cjk-strong.e2e.ts", "tests/markdown-inline-code-links.e2e.ts", diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css index 8fdba2baa0..c9e8d19d77 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.module.css @@ -17,6 +17,28 @@ gap: 16px; } +/* Wide markdown tables (the renderer's ≥4-column md-table-wide hook) span + the whole transcript width instead of clipping at the 748px message + column, while the table content keeps starting at its normal x (the lead + padding compensates the negative margin) — deepsuite chat TableWrapper + parity, with 100cqw (the ChatView scroll container) standing in for its + JS-measured --dsl-virtual-list-width. `--dsh-table-spare` clamps to zero + when the transcript is narrower than the message column, so narrow + viewports keep the plain in-column scroll with no sideways shift. + Percentages resolve against the wrapper's containing block, so a table + indented inside a list still reaches the same transcript edges. */ +.body :global(.md-table-wide) { + --dsh-table-spare: max(0px, calc((100cqw - var(--dsh-chat-content-width)) / 2)); + --dsh-table-lead: calc(var(--dsh-table-spare) + min(var(--dsh-chat-content-width), 100cqw) - 100%); + box-sizing: border-box; + width: calc(100% + var(--dsh-table-lead) + var(--dsh-table-spare)); + /* The base .tableScroll caps at the column (max-width: 100%); the breakout + is exactly the case that must exceed it. */ + max-width: none; + margin-left: calc(-1 * var(--dsh-table-lead)); + padding-left: var(--dsh-table-lead); +} + /* Interrupted-turn terminal marker: quiet inline tag, no animation. */ .stopped { align-self: flex-start; diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.module.css b/packages/client/ui-conversation/src/client/chat/ChatView.module.css index ffee12984f..73fa688f95 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.module.css +++ b/packages/client/ui-conversation/src/client/chat/ChatView.module.css @@ -18,6 +18,11 @@ /* Sides = composer clearance + 16px: on narrow viewports the transcript stays exactly 32px narrower than the input card (the shared width rule). */ padding: 16px calc(var(--dsh-composer-side-clearance) + 16px); + /* Inline-size query container: wide markdown tables size their breakout + against this box's content width (100cqw in AssistantMarkdown's + md-table-wide rule) — the CSS stand-in for deepsuite chat's JS-measured + --dsl-virtual-list-width. */ + container-type: inline-size; } :global([data-conversation-scroll]) .root { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 06ca10e568..6856283fcc 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md -README.md: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9 -README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf +README.md: 0d7848112b650a322965ad52a38d23e0145e7921 +README.zh.md: 475af21d5a7bc1bc48115b171d92b9a0d44df309 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a675b3cd0a..0d7848112b 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); Chromium keyboard-focuses the scrollable wrapper by default ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index aa67993ec8..475af21d5a 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -14,7 +14,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);Chromium 默认让可滚动包裹层可键盘聚焦([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index b62e66e86e..969cf41342 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -177,12 +177,31 @@ overscroll-behavior-x: contain; } +/* Chromium keyboard-focuses scrollable containers by default; the ring uses + the sheet's link focus color. */ +.tableScroll:focus-visible { + outline: none; + box-shadow: 0 0 0 2px var(--dsw-alias-state-business-primary); +} + +/* Many-column tables (the renderer's `md-table-wide` hook) keep their + natural width and scroll inside the wrapper; a hosting layout may widen + the wrapper through the hook (deepsuite chat TableWrapper parity). */ .tableScroll table { border-collapse: collapse; width: max-content; max-width: max-content; } +/* Tables under four columns, and any table inside a blockquote, fill the + column instead: cells wrap down to their minimum readable width, and the + wrapper's scroll only remains for the below-floor case (deepsuite chat + parity: `.wrapper:not(:has(th:nth-child(4), td:nth-child(4)))`). */ +.tableFill table { + width: 100%; + max-width: none; +} + .tableScroll th { text-align: start; padding: 10px 16px; diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 452e0fc475..12d73bb44b 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -18,6 +18,7 @@ import { Fragment, createElement } from 'react' import type { Key, ReactNode } from 'react' +import clsx from 'clsx' import type * as Md from 'mdast' import type {} from 'mdast-util-math' import { normalizeUri } from 'micromark-util-sanitize-uri' @@ -123,6 +124,8 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inside a blockquote's children: tables there always fill the quote's width. */ + readonly inBlockquote?: boolean /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ readonly fileMentions: MarkdownFileMentions | undefined /** Inside an anchor's children: interactive mentions must not nest there. */ @@ -213,7 +216,10 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'blockquote': return (
- {wrapBlockChildren(renderChildren(node.children, context).filter(child => child !== null), true)} + {wrapBlockChildren( + renderChildren(node.children, { ...context, inBlockquote: true }).filter(child => child !== null), + true, + )}
) case 'thematicBreak': @@ -393,8 +399,15 @@ function renderListItem( function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): ReactNode { const align = node.align ?? null const [headRow, ...bodyRows] = node.children + const columns = align === null ? headRow?.children.length ?? 0 : align.length + // Four or more columns read as a comparison matrix: the wrapper keeps the + // table at natural width and exposes the stable `md-table-wide` hook so a + // hosting layout (the chat transcript) can widen it past the message + // column. Narrower tables — and any table inside a blockquote — fill the + // column and wrap instead (deepsuite chat TableWrapper parity). + const wide = columns >= 4 && context.inBlockquote !== true return ( -
+
{headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}} {bodyRows.length > 0 && ( diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt index cf6b2ba4ad..83b008e2d5 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.settled.txt @@ -22,7 +22,7 @@ #text "Unbalanced errors render the error arm: " #text "\\frac{" -
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt index 639a2fca84..a56b3c338d 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/math-edge-cases.streaming.txt @@ -3,7 +3,7 @@ #text "Trusted commands stay off: $\\href{javascript:alert(1)}{unsafe}$."

#text "Unbalanced errors render the error arm: $\\frac{$" -

+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt index 2c2d9d7e0f..ba1552d480 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt index 2c2d9d7e0f..ba1552d480 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-header-only.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt new file mode 100644 index 0000000000..9b805440be --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt @@ -0,0 +1,46 @@ +
+
+
+ + + + +
+ #text "C1" + + #text "C2" + + #text "C3" + + #text "C4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" +
+
+ + + + + +
+ #text "Q1" + + #text "Q2" + + #text "Q3" + + #text "Q4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt new file mode 100644 index 0000000000..9b805440be --- /dev/null +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt @@ -0,0 +1,46 @@ +
+
+ + + + + +
+ #text "C1" + + #text "C2" + + #text "C3" + + #text "C4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" +
+
+ + + + + +
+ #text "Q1" + + #text "Q2" + + #text "Q3" + + #text "Q4" +
+ #text "a" + + #text "b" + + #text "c" + + #text "d" diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt index 6a669ffe2f..2ce936ddcc 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt index 6a669ffe2f..2ce936ddcc 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx index 70197763da..093b84a133 100644 --- a/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-dom-parity.client.spec.tsx @@ -163,6 +163,17 @@ const CORPUS: Record = { 'after', ].join('\n'), 'table-header-only': '| a | b |\n| --- | --- |\n\nafter', + // Four columns take the md-table-wide hook at top level; the same table + // inside a blockquote falls back to the fill arm. + 'table-wide-and-blockquote': [ + '| C1 | C2 | C3 | C4 |', + '| --- | --- | --- | --- |', + '| a | b | c | d |', + '', + '> | Q1 | Q2 | Q3 | Q4 |', + '> | --- | --- | --- | --- |', + '> | a | b | c | d |', + ].join('\n'), 'inline-code-with-newline': 'Spans `a\nb` across a line.', 'links-and-autolinks': [ '[https ok](https://example.com "with title") and [mailto ok](mailto:dev@example.com).', diff --git a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx index 48f59c7479..dbad9ef164 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.client.spec.tsx @@ -101,6 +101,14 @@ describe('renderBlocks over hand-built trees', () => { expect(container.querySelector('td')?.textContent).toBe('short') }) + it('renders a rowless align-less table as an empty fill wrapper', () => { + // Zero columns is below the wide threshold, so the fill arm applies. + const container = renderNodes([{ type: 'table', children: [] }]) + const wrapper = container.querySelector('table')?.parentElement + expect(wrapper?.className).not.toContain('md-table-wide') + expect(container.querySelector('table')?.childElementCount).toBe(0) + }) + it('pads rows against the alignment width with empty cells', () => { const container = renderNodes([ { diff --git a/tsconfig.host.json b/tsconfig.host.json index bb885e7ecc..d0b5808a48 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -51,6 +51,7 @@ "apps/web/tests/message-feedback-layout.e2e.ts", "apps/web/tests/markdown-images.e2e.ts", "apps/web/tests/reference-composer.e2e.ts", + "apps/web/tests/markdown-wide-table.e2e.ts", "apps/web/tests/math-rendering.e2e.ts", "apps/web/tests/markdown-cjk-strong.e2e.ts", "apps/web/tests/markdown-inline-code-links.e2e.ts", From a33ed4ddf8c762e98759b7725f5095046ca270b4 Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 20 Aug 2026 13:15:58 +0800 Subject: [PATCH 04/34] ci: stop PR gray checks from lifecycle and release publish jobs Remove the three skipped (gray) checks from the PR check panel without changing functional semantics: - issue-lifecycle: remove the job-level 'if' that skipped the lifecycle job on non-changes-requested pull_request_review events, so it now runs and reports success (the lifecycle handler already no-ops for approved/commented reviews). The changes-requested board transition is unchanged. - release.yml / release-vendor.yml: drop the publish job (and its workflow_dispatch 'publish' input + RELEASE_PUBLISH pass-through) so it no longer appears as a skipped Publish-to-npm check on PRs; the files keep the pack job that validates tarballs on PR/push. - new release-publish.yml / release-vendor-publish.yml: manual workflow_dispatch only, repack on the current tree then publish, so publication behaves exactly as the old publish job (explicit dispatch, uses the packed bytes) but never shows as a PR check. Update the 2026-08-10 review-status note (en/zh/i18n) and the issue-lifecycle spec assertion to match the unconditional lifecycle job. Verification: ci-workflow.spec.ts 19/19, all five workflows YAML-parse, typecheck clean, verify-translation-pairing consistent, note-format 582. --- ...-event-directed-pr-review-status.i18n.yaml | 4 +- ...6-08-10-event-directed-pr-review-status.md | 4 +- ...8-10-event-directed-pr-review-status.zh.md | 4 +- .github/workflows/issue-lifecycle.yml | 5 +- .github/workflows/release-publish.yml | 131 ++++++++++++++++++ .github/workflows/release-vendor-publish.yml | 114 +++++++++++++++ .github/workflows/release-vendor.yml | 61 +------- .github/workflows/release.yml | 63 +-------- scripts/ci-workflow.spec.ts | 22 +-- 9 files changed, 279 insertions(+), 129 deletions(-) create mode 100644 .github/workflows/release-publish.yml create mode 100644 .github/workflows/release-vendor-publish.yml diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml index 08607d5317..f3f9f10975 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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/process/2026-08-10-event-directed-pr-review-status.md -2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1 -2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7 +2026-08-10-event-directed-pr-review-status.md: bdaaa07c47d45eb002ed7c026683a800f67d0fca +2026-08-10-event-directed-pr-review-status.zh.md: 0ca4154a78e82156687b4fc5efb62745f3b2af63 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md index 9db9c64fc8..bdaaa07c47 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -12,7 +12,7 @@ A monotonic projection also cannot return an automation-owned Issue from `In rev ## Decision -The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed. +The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions run their lifecycle job but no-op (they never reach the Project token step), while dismissed reviews are not subscribed. Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. @@ -22,7 +22,7 @@ The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` ## Verification -[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger. +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the absence of a job-level `if` (so approved/commented reviews pass rather than skip), and the separate `ready_for_review` policy trigger. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md index 381a3f64a6..0ca4154a78 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -12,7 +12,7 @@ Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责 ## 决策 -Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。 +Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,生命周期作业会运行但空操作(不会走到创建 Project token 一步);dismissed 评审则不在订阅范围内。 工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 @@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review ## 验证 -[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。 +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、作业无 job 级 `if`(使 approved/commented 评审以 pass 而非 skip 呈现),以及独立的 `ready_for_review` 策略触发器。 ## 考虑过的替代方案 diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index e324cfefc2..70732de6a2 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,7 +36,10 @@ concurrency: jobs: lifecycle: name: Issue lifecycle - if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }} + # Run on every pull_request_review event, not only changes_requested, so the + # check shows a passing result instead of a gray "skipped" segment. The + # lifecycle handler itself no-ops (returns success) for approved/commented + # reviews; only a changes_requested review drives the Project board. runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 0000000000..7107ba0073 --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,131 @@ +# Publish the dsh release sequence to npm. This workflow is manual-only +# (workflow_dispatch) and intentionally does not listen to pull_request or push: +# publication must always be an explicit, reviewed act from a dsh-v* tag, and it +# must never appear as a PR check. It repacks the current tree before publishing +# so the bytes uploaded are exactly what this dispatch produced. +name: Release publish (dsh) + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + DSH_TELEMETRY_DISABLED: '1' + +jobs: + pack: + name: Pack npm tarballs + runs-on: ubuntu-24.04 + steps: + # Complete history: the release scripts read tags. + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Verify release version + env: + RELEASE_PUBLISH: 'true' + run: pnpm run release:verify --family dsh + + - name: Build + run: pnpm run build + + - name: Pack release tarballs + run: pnpm run release:pack --family dsh --out dist/npm + + # The harness packages declare the vendored framework as a peer, and this + # verification must not depend on the registry already carrying matching + # versions — one pull request may bump both families before either + # publishes — so it installs that family's pack output too. Only dist/npm + # is published. + - name: Pack the vendored framework for verification + run: pnpm run release:pack --family vendor --out dist/npm-vendor + + # dsh-sandbox-local declares the Landlock entry as a runtime dependency, so + # the verification needs its tarball. Its platform packages stay out: they + # are optional, and building them needs a musl toolchain per architecture. + - name: Pack the Landlock entry for verification + run: | + pnpm --dir native/landlock-run run build:ts + pnpm --dir native/landlock-run/packages/entry pack --pack-destination "$PWD/dist/npm-landlock" + + - name: Verify packed install + run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor --from dist/npm-landlock + + - uses: actions/upload-artifact@v4 + with: + name: dsh-npm-tarballs + path: dist/npm/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + needs: pack + runs-on: ubuntu-24.04 + # Required reviewers and the allowed tags live on the environment; this is + # the only job in the sequence that can write to the registry. + environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false + permissions: + contents: read + steps: + # Checkout and install carry the release scripts only. There is no build + # step: publication uploads the bytes the pack job produced. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Install (immutable, no package scripts) + run: pnpm install --frozen-lockfile --ignore-scripts + + - uses: actions/download-artifact@v4 + with: + name: dsh-npm-tarballs + path: dist/npm + + - name: Publish tarballs + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm run release:publish --family dsh --from dist/npm diff --git a/.github/workflows/release-vendor-publish.yml b/.github/workflows/release-vendor-publish.yml new file mode 100644 index 0000000000..a50a408025 --- /dev/null +++ b/.github/workflows/release-vendor-publish.yml @@ -0,0 +1,114 @@ +# Publish the vendored framework sequence to npm. This workflow is manual-only +# (workflow_dispatch) and intentionally does not listen to pull_request or push: +# publication must always be an explicit, reviewed act from a vendor-* tag, and +# it must never appear as a PR check. It repacks the current tree before +# publishing so the bytes uploaded are exactly what this dispatch produced. +name: Release publish (vendor) + +on: + workflow_dispatch: + +permissions: + contents: read + +env: + PRIMARY_NODE_VERSION: '24' + DSH_TELEMETRY_DISABLED: '1' + +jobs: + pack: + name: Pack npm tarballs + runs-on: ubuntu-24.04 + steps: + # Complete history: the release scripts read tags. + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Verify release version + env: + RELEASE_PUBLISH: 'true' + run: pnpm run release:verify --family vendor + + # The vendored packages publish their own sources and build outputs; the + # host build produces what their manifests select. + - name: Build + run: pnpm run build:lib:host + + - name: Pack release tarballs + run: pnpm run release:pack --family vendor --out dist/npm-vendor + + - name: Verify packed install + run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor + + - uses: actions/upload-artifact@v4 + with: + name: vendor-npm-tarballs + path: dist/npm-vendor/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + needs: pack + runs-on: ubuntu-24.04 + environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false + permissions: + contents: read + steps: + # Checkout and install carry the release scripts only; no build step. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Install (immutable, no package scripts) + run: pnpm install --frozen-lockfile --ignore-scripts + + - uses: actions/download-artifact@v4 + with: + name: vendor-npm-tarballs + path: dist/npm-vendor + + - name: Publish tarballs + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm run release:publish --family vendor --from dist/npm-vendor diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index c8af47251e..34290bb7a2 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -1,10 +1,11 @@ -# Pack and publish the vendored framework sequence: the nine rescoped Cordis -# packages under vendor/, each on its own version line. This sequence releases -# independently of dsh and of the native packages. +# Pack the vendored framework sequence: the nine rescoped Cordis packages under +# vendor/, each on its own version line. This sequence releases independently of +# dsh and of the native packages. # # Pack runs without credentials on every pull request and master push. -# Publication is a manual dispatch from a vendor-* tag; a vendor release can -# carry several versions, so each package has its own tag. +# Publication is a manual workflow_dispatch of release-vendor-publish.yml from a +# vendor-* tag; a vendor release can carry several versions, so each package has +# its own tag. name: Release (vendor) on: @@ -12,19 +13,12 @@ on: push: branches: [master] workflow_dispatch: - inputs: - publish: - description: Publish the packed tarballs to npm. Must run from a vendor-* tag. - required: true - type: boolean - default: false permissions: contents: read concurrency: - # Pack runs per ref so concurrent pull requests never displace each - # other; the publish job below serializes the shared dist-tag state. + # Pack runs per ref so concurrent pull requests never displace each other. group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -70,8 +64,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Verify release version - env: - RELEASE_PUBLISH: ${{ inputs.publish }} run: pnpm run release:verify --family vendor # The vendored packages publish their own sources and build outputs; the @@ -91,42 +83,3 @@ jobs: path: dist/npm-vendor/* if-no-files-found: error retention-days: 7 - - publish: - name: Publish to npm - if: inputs.publish - needs: pack - runs-on: ubuntu-24.04 - environment: npm-publish - concurrency: - group: Release-publish - cancel-in-progress: false - permissions: - contents: read - steps: - # Checkout and install carry the release scripts only; no build step. - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - registry-url: https://registry.npmjs.org - - - name: Install (immutable, no package scripts) - run: pnpm install --frozen-lockfile --ignore-scripts - - - uses: actions/download-artifact@v4 - with: - name: vendor-npm-tarballs - path: dist/npm-vendor - - - name: Publish tarballs - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm run release:publish --family vendor --from dist/npm-vendor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08296468bf..2b84a299b9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,10 +1,10 @@ -# Pack and publish the dsh release sequence: every package under packages/ plus -# the apps/ entries, all on one version. The vendored framework and the native -# packages are separate sequences with their own workflows and version lines. +# Pack the dsh release sequence: every package under packages/ plus the apps/ +# entries, all on one version. The vendored framework and the native packages are +# separate sequences with their own workflows and version lines. # # Pack runs without credentials on every pull request and master push, so a -# pull request proves the whole publish set still packs. Publication is a -# manual dispatch from a dsh-v* tag and consumes exactly the packed bytes. +# pull request proves the whole publish set still packs. Publication is a manual +# workflow_dispatch of release-publish.yml from a dsh-v* tag. name: Release (dsh) on: @@ -12,19 +12,12 @@ on: push: branches: [master] workflow_dispatch: - inputs: - publish: - description: Publish the packed tarballs to npm. Must run from a dsh-v* tag. - required: true - type: boolean - default: false permissions: contents: read concurrency: - # Pack runs per ref so concurrent pull requests never displace each - # other; the publish job below serializes the shared dist-tag state. + # Pack runs per ref so concurrent pull requests never displace each other. group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -70,8 +63,6 @@ jobs: run: pnpm install --frozen-lockfile - name: Verify release version - env: - RELEASE_PUBLISH: ${{ inputs.publish }} run: pnpm run release:verify --family dsh - name: Build @@ -105,45 +96,3 @@ jobs: path: dist/npm/* if-no-files-found: error retention-days: 7 - - publish: - name: Publish to npm - if: inputs.publish - needs: pack - runs-on: ubuntu-24.04 - # Required reviewers and the allowed tags live on the environment; this is - # the only step in the sequence that can write to the registry. - environment: npm-publish - concurrency: - group: Release-publish - cancel-in-progress: false - permissions: - contents: read - steps: - # Checkout and install carry the release scripts only. There is no build - # step: publication uploads the bytes the pack job produced. - - uses: actions/checkout@v6 - with: - persist-credentials: false - - - uses: pnpm/action-setup@v4 - with: - dest: ${{ runner.temp }}/setup-pnpm - - - uses: actions/setup-node@v6 - with: - node-version: ${{ env.PRIMARY_NODE_VERSION }} - registry-url: https://registry.npmjs.org - - - name: Install (immutable, no package scripts) - run: pnpm install --frozen-lockfile --ignore-scripts - - - uses: actions/download-artifact@v4 - with: - name: dsh-npm-tarballs - path: dist/npm - - - name: Publish tarballs - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - run: pnpm run release:publish --family dsh --from dist/npm diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 721c9987b6..a13ceafe47 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -417,20 +417,20 @@ describe('Python release workflows', () => { }) describe('Issue lifecycle workflow', () => { - it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { + it('runs the lifecycle job on every PR/review event so it passes instead of skipping', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') - const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') - const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') - const lifecycleJob = workflowJob(lifecycle, 'lifecycle') const policy = loadWorkflow('.github/workflows/issue-policy.yml') - const policyPullRequest = workflowEvent(policy, 'pull_request') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') - expect(lifecyclePullRequest.types).not.toContain('ready_for_review') - expect(lifecyclePullRequest.types).toContain('review_requested') - expect(lifecycleReview.types).toEqual(['submitted']) - expect(lifecycleJob.if).toBe( - "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", - ) + // The lifecycle job has no workflow-level `if`, so it is listed on every + // pull_request / pull_request_review event and reports success (the handler + // no-ops for non-changes-requested reviews) instead of a gray "skipped" check. + expect(lifecycle.on).toHaveProperty('pull_request') + expect(lifecycle.on).toHaveProperty('pull_request_review') + expect(lifecycleJob.if).toBeUndefined() + + // issue-policy owns PR validation; it is read-only and a real gate. + const policyPullRequest = workflowEvent(policy, 'pull_request') expect(policyPullRequest.types).toContain('ready_for_review') }) }) From c9ce61136d3665c3d9e8591281c33d4600093c9f Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Thu, 20 Aug 2026 14:45:15 +0800 Subject: [PATCH 05/34] feat(web): reveal the wide-table scrollbar on hover instead of painting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The themed WebKit scrollbar skin keeps a wide table's horizontal bar permanently painted. Chromium never repaints state-conditioned scrollbar styles (hover-conditioned ::-webkit-scrollbar* rules and :hover scrollbar-color changes both compute but never reach the painted bar, measured headed and headless), so the hover reveal toggles overflow-x itself: hidden at rest with a padding-bottom matching the themed bar height, auto on hover or keyboard focus with the padding released — the appearing bar exactly replaces the padding and nothing below shifts. Resting hidden overflow drops Chromium's implicit scroller focusability, so wide wrappers carry an explicit tabindex for arrow-key scrolling. --- ...-19-web-markdown-wide-table-view.i18n.yaml | 4 +-- ...2026-08-19-web-markdown-wide-table-view.md | 2 +- ...6-08-19-web-markdown-wide-table-view.zh.md | 2 +- apps/web/tests/markdown-wide-table.e2e.ts | 28 +++++++++++++++++++ .../client/ui-primitives/README.i18n.yaml | 4 +-- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../src/markdown/MarkdownText.module.css | 20 +++++++++++++ .../ui-primitives/src/markdown/render.tsx | 12 ++++++-- .../table-wide-and-blockquote.settled.txt | 2 +- .../table-wide-and-blockquote.streaming.txt | 2 +- .../table-with-alignment.settled.txt | 2 +- .../table-with-alignment.streaming.txt | 2 +- 13 files changed, 70 insertions(+), 14 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml index 19561bb6f0..e4b5654510 100644 --- a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.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-19-web-markdown-wide-table-view.md -2026-08-19-web-markdown-wide-table-view.md: 847b3f055edd29eeb0b6f3f8bec5b97252aca4f4 -2026-08-19-web-markdown-wide-table-view.zh.md: d2fcc18f883f71a3fdbfbab6476c87b1c9840b21 +2026-08-19-web-markdown-wide-table-view.md: fc025f6755caa2ca160b02f0c03ae80fd04cc1c7 +2026-08-19-web-markdown-wide-table-view.zh.md: 4d13a29f69fac31f9796dae064726d4d3a779ffd diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md index 847b3f055e..fc025f6755 100644 --- a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md @@ -26,7 +26,7 @@ English | [中文](2026-08-19-web-markdown-wide-table-view.zh.md) ## Consequences -An ordinary wide table reads in place with wrapped cells; a many-column table keeps its readable natural width, spans the whole transcript where the layout has spare width, and scrolls for the remainder — with no interaction required and nothing to restore. Chromium keyboard-focuses scrollable containers by default, so the wrapper stays keyboard-scrollable without added attributes (a `:focus-visible` ring marks it). Two knowledge edges: `container-type: inline-size` on ChatView's `.scroll` makes it the nearest query container for anything inside the transcript that later uses container units, and sub-four-column tables now always stretch to the full column width (deepsuite chat behavior) rather than shrink-wrapping short content. +An ordinary wide table reads in place with wrapped cells; a many-column table keeps its readable natural width, spans the whole transcript where the layout has spare width, and scrolls for the remainder — with no interaction required and nothing to restore. A wide table's horizontal bar reveals on hover instead of staying painted: Chromium never repaints state-conditioned scrollbar styles (neither hover-conditioned `::-webkit-scrollbar*` rules nor a `:hover` `scrollbar-color` change reaches the painted bar — measured headed and headless), so the reveal toggles `overflow-x` itself (`hidden` at rest, `auto` on hover or focus), with resting `padding-bottom` matching the themed bar height so the appearing bar replaces it without moving content below. Resting `overflow-x: hidden` drops Chromium's implicit scroller focusability, so wide wrappers carry an explicit `tabindex="0"` (a `:focus-visible` ring marks them, and focus restores scrolling for arrow keys). Two knowledge edges: `container-type: inline-size` on ChatView's `.scroll` makes it the nearest query container for anything inside the transcript that later uses container units, and sub-four-column tables now always stretch to the full column width (deepsuite chat behavior) rather than shrink-wrapping short content. ## Testing diff --git a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md index d2fcc18f88..4d13a29f69 100644 --- a/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md +++ b/.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.zh.md @@ -26,7 +26,7 @@ Status: implemented ## 后果 -普通宽表原地换行阅读;多列表格保持可读的自然宽度,在布局有空余处横跨整个转录区,剩余部分滚动——无需任何交互,也没有状态要恢复。Chromium 默认让可滚动容器可键盘聚焦,因此包裹层无需附加属性即可键盘滚动(`:focus-visible` 有焦点圈)。两个需要知道的点:ChatView `.scroll` 上的 `container-type: inline-size` 使它成为转录区内后续使用容器单位的最近查询容器;不足四列的表格现在总是拉伸到整列宽(deepsuite chat 行为),而不是按内容收缩。 +普通宽表原地换行阅读;多列表格保持可读的自然宽度,在布局有空余处横跨整个转录区,剩余部分滚动——无需任何交互,也没有状态要恢复。宽表的横向滚动条悬停才出现、不再常驻:Chromium 从不重绘状态条件化的滚动条样式(悬停条件化的 `::-webkit-scrollbar*` 规则和 `:hover` 下的 `scrollbar-color` 变化都到不了已绘制的滚动条——有头与无头模式均已实测),因此显隐切换的是 `overflow-x` 本身(静止 `hidden`,悬停或聚焦 `auto`),静止时的 `padding-bottom` 与主题滚动条高度一致,出现的滚动条恰好顶替它、下方内容不动。静止的 `overflow-x: hidden` 会失去 Chromium 对滚动容器的隐式可聚焦性,因此宽表包裹层带显式 `tabindex="0"`(`:focus-visible` 有焦点圈,聚焦后方向键可滚)。两个需要知道的点:ChatView `.scroll` 上的 `container-type: inline-size` 使它成为转录区内后续使用容器单位的最近查询容器;不足四列的表格现在总是拉伸到整列宽(deepsuite chat 行为),而不是按内容收缩。 ## 测试 diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index da2c371085..bb897ea364 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -370,6 +370,34 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro expect(tripwire.pageErrors).toEqual([]) }, 120_000) + it('reveals the wide table scrollbar on hover only', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar')) + await sweep() + await settleAt(1680) + const wide = page.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER }) + // Chromium never repaints state-conditioned scrollbar STYLES, so the + // hover reveal toggles overflow-x itself; the resting padding matches + // the bar height so the swap does not move anything below. Both are + // ordinary properties whose computed values follow :hover. + const overflowState = () => wide.evaluate(element => [ + getComputedStyle(element).overflowX, + getComputedStyle(element).paddingBottom, + ].join(' ')) + // Park the pointer away and drop focus: the keyboard case above leaves + // the wrapper focused, and focus-visible also reveals the bar. + await page.mouse.move(4, 4) + await wide.evaluate((element) => { element.blur() }) + await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') + // Resting hidden overflow keeps the scroll position reachable and intact. + expect(await wide.evaluate(element => element.scrollLeft)).toBeGreaterThanOrEqual(0) + await wide.hover() + await expect.poll(overflowState, { timeout: 5_000 }).toBe('auto 0px') + // Pointer leaves: the bar rests hidden again. + await page.mouse.move(4, 4) + await expect.poll(overflowState, { timeout: 5_000 }).toBe('hidden 8px') + expect(tripwire.pageErrors).toEqual([]) + }, 120_000) + it('keeps the fill/scroll relations under page zoom', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-zoom')) await sweep() diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 6856283fcc..e6ed8300ac 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.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 packages/client/ui-primitives/README.md -README.md: 0d7848112b650a322965ad52a38d23e0145e7921 -README.zh.md: 475af21d5a7bc1bc48115b171d92b9a0d44df309 +README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81 +README.zh.md: 5a78e683f3bb94f8b9d3a64b0d4ba1f3f3df9d85 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 0d7848112b..7822a5d41e 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -14,7 +14,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); Chromium keyboard-focuses the scrollable wrapper by default ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). Tables size by column count (deepsuite chat parity): under four columns — or inside a blockquote — a table fills its column and wraps cell text down to the cells' minimum readable width, while four-or-more-column tables keep their natural width, scroll horizontally inside their wrapper, and carry the stable `md-table-wide` class so a hosting layout can widen the wrapper past its column (the chat transcript's container-query breakout in `dsh-client-ui-conversation`); a wide table's horizontal bar reveals on hover or keyboard focus (the wrapper carries `tabindex="0"`) instead of staying painted ([decision record](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 475af21d5a..5a78e683f3 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -14,7 +14,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);Chromium 默认让可滚动包裹层可键盘聚焦([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有该组件的视图为命名真实文件的行内代码添加可点击入口:token 保留代码样式,并获得一个连接到解析所得 opener 的按钮,按钮带有解析器提供的无障碍标签和以完整路径为值的 `title`。渲染器绝不猜测哪些内容像路径:未解析的 token 保持不可交互;文件提及仅应用于已定稿的渲染(流式缓存不得固化可能过期的 handler);锚点内的 token 也保持不可交互,因为按钮不能嵌套其中。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性约定](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。表格按列数决定尺寸(对齐 deepsuite chat):不足四列——或位于 blockquote 内——的表格填满所在列,单元格文本换行收缩至最小可读列宽;四列及以上的表格保持自然宽度、在包裹层内横向滚动,并携带稳定的 `md-table-wide` 类,供宿主布局把包裹层加宽到所在列之外(`dsh-client-ui-conversation` 中聊天转录区的容器查询突破样式);宽表的横向滚动条在悬停或键盘聚焦(包裹层带 `tabindex="0"`)时才出现、不再常驻([决策记录](../../../.agents/notes/implemented/feature/2026-08-19-web-markdown-wide-table-view.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 969cf41342..f8d45cd4f6 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -177,6 +177,26 @@ overscroll-behavior-x: contain; } +/* Wide tables reveal their horizontal bar on hover (or keyboard focus) + instead of keeping it painted. Chromium never repaints state-conditioned + scrollbar STYLES (neither hover-conditioned `::-webkit-scrollbar*` rules + nor a :hover `scrollbar-color` change reaches the painted bar), so the + toggle is `overflow-x` itself — a layout change repaints reliably. The + resting padding matches the themed bar's height, so on an overflowing + table the appearing bar exactly replaces it and nothing below shifts. + Wheel and trackpad scrolling need the pointer over the table, which is + already the hover that re-enables `auto`. */ +.tableScroll:global(.md-table-wide) { + overflow-x: hidden; + padding-bottom: var(--dsh-scrollbar-width, 8px); +} + +.tableScroll:global(.md-table-wide):hover, +.tableScroll:global(.md-table-wide):focus-visible { + overflow-x: auto; + padding-bottom: 0; +} + /* Chromium keyboard-focuses scrollable containers by default; the ring uses the sheet's link focus color. */ .tableScroll:focus-visible { diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 12d73bb44b..de713858cf 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -400,14 +400,22 @@ function renderTable(node: Md.Table, key: Key, context: MarkdownRenderContext): const align = node.align ?? null const [headRow, ...bodyRows] = node.children const columns = align === null ? headRow?.children.length ?? 0 : align.length - // Four or more columns read as a comparison matrix: the wrapper keeps the + // Four or more columns read as a comparison matrix: the block keeps the // table at natural width and exposes the stable `md-table-wide` hook so a // hosting layout (the chat transcript) can widen it past the message // column. Narrower tables — and any table inside a blockquote — fill the // column and wrap instead (deepsuite chat TableWrapper parity). const wide = columns >= 4 && context.inBlockquote !== true return ( -
+ // Wide tables rest with overflow-x hidden (the hover-revealed bar in + // MarkdownText.module.css), which drops Chromium's implicit scroller + // focusability — the explicit tabindex keeps them keyboard-reachable, + // and :focus-visible restores scrolling. +
{headRow !== undefined && {renderTableRow(headRow, 'th', align, 0, context)}} {bodyRows.length > 0 && ( diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt index 9b805440be..c105766edf 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt index 9b805440be..c105766edf 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-wide-and-blockquote.streaming.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt index 2ce936ddcc..99a45c4e77 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.settled.txt @@ -1,5 +1,5 @@
-
+
diff --git a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt index 2ce936ddcc..99a45c4e77 100644 --- a/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt +++ b/packages/client/ui-primitives/tests/fixtures/markdown-dom/table-with-alignment.streaming.txt @@ -1,5 +1,5 @@
-
+
From 63d9de0eb3c580c08dd1a11be95c0a42066e8bff Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Thu, 20 Aug 2026 15:21:59 +0800 Subject: [PATCH 06/34] fix(cic): address gray-check PR review - official build, step-level gate, note sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address ds-review-bot findings on PR #2798: - release-publish.yml: use pnpm run build:official (not build) so the dsh pack step's verifyBuildArtifacts (families.ts:327, readClientBuildRecord with officialClientBuildEnvironment) finds the official client-build record; build would fail Pack release tarballs on a clean runner. - issue-lifecycle.yml: move the previous job-level if to step level on Create project token and Handle repository event, so approved/commented reviews pass (job reported success, no gray segment) without minting a write-capable App token or touching the board — preserving the original least-privilege property. - ci-workflow.spec.ts: lock the step-level gate on the two lifecycle steps, and add a release-workflow invariant test (release.yml/vendor are pack-only; release-publish.yml/vendor-publish.yml are workflow_dispatch-only with the npm-publish environment and Release-publish group) to prevent #2797 recurrence. - Update 2026-08-10-event-directed-pr-review-status and 2026-08-10-npm-release- sequences notes (en/zh/i18n) to the new split and step-level behavior. Verification: ci-workflow.spec.ts 14/14, typecheck clean, all five workflows YAML-parse, verify-translation-pairing consistent, note-format 582. --- ...-event-directed-pr-review-status.i18n.yaml | 4 +- ...6-08-10-event-directed-pr-review-status.md | 2 +- ...8-10-event-directed-pr-review-status.zh.md | 2 +- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 6 +-- .../2026-08-10-npm-release-sequences.zh.md | 6 +-- .github/workflows/issue-lifecycle.yml | 11 ++++-- .github/workflows/release-publish.yml | 2 +- scripts/ci-workflow.spec.ts | 39 +++++++++++++++++-- 9 files changed, 55 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml index f3f9f10975..d8c333dd88 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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/process/2026-08-10-event-directed-pr-review-status.md -2026-08-10-event-directed-pr-review-status.md: bdaaa07c47d45eb002ed7c026683a800f67d0fca -2026-08-10-event-directed-pr-review-status.zh.md: 0ca4154a78e82156687b4fc5efb62745f3b2af63 +2026-08-10-event-directed-pr-review-status.md: 3ed6038929d3c2c1e9cd82182978262ee363f5ab +2026-08-10-event-directed-pr-review-status.zh.md: 1fa8650057e53ab894c597b712720a3ee7a5c46a diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md index bdaaa07c47..3ed6038929 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -22,7 +22,7 @@ The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` ## Verification -[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the absence of a job-level `if` (so approved/commented reviews pass rather than skip), and the separate `ready_for_review` policy trigger. +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the job-level absence of `if` plus the step-level gate on the token/board steps (so approved/commented reviews pass without minting a token), and the separate `ready_for_review` policy trigger. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md index 0ca4154a78..1fa8650057 100644 --- a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -22,7 +22,7 @@ Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review ## 验证 -[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、作业无 job 级 `if`(使 approved/commented 评审以 pass 而非 skip 呈现),以及独立的 `ready_for_review` 策略触发器。 +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、job 级无 `if` 且 token/看板步骤带 step 级门控(使 approved/commented 评审以 pass 呈现且不铸 token),以及独立的 `ready_for_review` 策略触发器。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml index e3a181f63d..763362d57c 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: efeda91b6a85e1316c563cc04411878122096953 -2026-08-10-npm-release-sequences.zh.md: d905ac4b58691890d2aad955b87713a278dcb4f8 +2026-08-10-npm-release-sequences.md: c2cf540ff130f2a17a40068a0684c9bbc0e07ee3 +2026-08-10-npm-release-sequences.zh.md: 378c0a007ec30ad05fe9bcb9d068d095ccb658bc diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md index efeda91b6a..c2cf540ff1 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -105,11 +105,11 @@ The entity in this domain is a **release family**: a set of packages sharing one The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files. -### Workflow shape: pack everything at once, then publish as one set +### Workflow shape: pack on PR/push, publish from a manual dispatch workflow -The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; the `publish` job downloads that artifact and publishes each entry in order. The release set is one unit — half the packages can never reach the registry while the other half is still building. +The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; it lives in `release.yml` / `release-vendor.yml`. The release set is one unit — half the packages can never reach the registry while the other half is still building. -`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. `publish` is a manual dispatch, sits behind the `npm-publish` environment for human approval, and neither builds nor rebuilds — it uploads the bytes pack produced. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global group, because dist-tags are shared registry state. +`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. Publication lives in a separate `release-publish.yml` / `release-vendor-publish.yml` workflow that is `workflow_dispatch`-only (so it never appears as a PR check): it repacks the current tree and then publishes each entry in order, behind the `npm-publish` environment for human approval. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish workflow carries the global `Release-publish` group, because dist-tags are shared registry state. A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so `release.yml` packs the vendored family for verification while publishing only its own set. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md index d905ac4b58..378c0a007e 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -105,11 +105,11 @@ registry 的两个行为决定了「怎么尝试一次发布」。写入之间 dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。 -### workflow 形状:一次性 pack 全部,再统一 publish +### workflow 形状:PR/push 上 pack,从手动 dispatch 工作流发布 -`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;`publish` job 下载那一份 artifact,按顺序逐个发布。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 +`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;它位于 `release.yml` / `release-vendor.yml`。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 -`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。`publish` 是手动 dispatch,挂在 `npm-publish` environment 后面等人工审批,且既不构建也不重建——它上传的就是 pack 产出的字节。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。 +`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。发布则位于独立的 `release-publish.yml` / `release-vendor-publish.yml` 工作流,仅 `workflow_dispatch`(因此不会作为 PR check 出现):它重新打包当前树,再按顺序逐个发布,挂在 `npm-publish` environment 后面等人工审批。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局 `Release-publish` 分组落在发布工作流上,因为 dist-tag 是共享的 registry 状态。 dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份。 diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 70732de6a2..8e2265fb19 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,10 +36,11 @@ concurrency: jobs: lifecycle: name: Issue lifecycle - # Run on every pull_request_review event, not only changes_requested, so the - # check shows a passing result instead of a gray "skipped" segment. The - # lifecycle handler itself no-ops (returns success) for approved/commented - # reviews; only a changes_requested review drives the Project board. + # Runs on every pull_request_review event so the check reports success rather + # than a gray "skipped" segment. The token-creating and board-mutating steps + # are gated at step level (a skipped step does not gray the job): only a + # changes_requested review drives the Project board; approved/commented + # reviews never mint a write-capable App token. runs-on: ubuntu-latest steps: - name: Check out trusted policy @@ -49,6 +50,7 @@ jobs: persist-credentials: false - name: Create project token id: app-token + if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 with: client-id: ${{ vars.DSH_ISSUE_APP_CLIENT_ID }} @@ -56,6 +58,7 @@ jobs: owner: deepseek-harness repositories: deepseek-harness - name: Handle repository event + if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} run: node .github/issue-management/policy.mjs lifecycle diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml index 7107ba0073..143df14caa 100644 --- a/.github/workflows/release-publish.yml +++ b/.github/workflows/release-publish.yml @@ -58,7 +58,7 @@ jobs: run: pnpm run release:verify --family dsh - name: Build - run: pnpm run build + run: pnpm run build:official - name: Pack release tarballs run: pnpm run release:pack --family dsh --out dist/npm diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index a13ceafe47..0740a6a6fb 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -417,17 +417,25 @@ describe('Python release workflows', () => { }) describe('Issue lifecycle workflow', () => { - it('runs the lifecycle job on every PR/review event so it passes instead of skipping', () => { + it('runs the lifecycle job on every PR/review event but gates token and board steps', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') const policy = loadWorkflow('.github/workflows/issue-policy.yml') const lifecycleJob = workflowJob(lifecycle, 'lifecycle') + if (!Array.isArray(lifecycleJob.steps)) throw new TypeError('Issue lifecycle job must define steps') - // The lifecycle job has no workflow-level `if`, so it is listed on every - // pull_request / pull_request_review event and reports success (the handler - // no-ops for non-changes-requested reviews) instead of a gray "skipped" check. + // The job has no job-level `if`, so it is listed on every pull_request / + // pull_request_review event and reports success instead of a gray skip. The + // write-capable steps are gated at step level so approved/commented reviews + // never mint a Project/Issue App token nor touch the board. expect(lifecycle.on).toHaveProperty('pull_request') expect(lifecycle.on).toHaveProperty('pull_request_review') expect(lifecycleJob.if).toBeUndefined() + const gated = "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}" + const steps = lifecycleJob.steps.filter(isRecord) + const tokenStep = steps.find(s => s.name === 'Create project token') + const handleStep = steps.find(s => s.name === 'Handle repository event') + expect(tokenStep).toMatchObject({ if: gated }) + expect(handleStep).toMatchObject({ if: gated }) // issue-policy owns PR validation; it is read-only and a real gate. const policyPullRequest = workflowEvent(policy, 'pull_request') @@ -435,6 +443,29 @@ describe('Issue lifecycle workflow', () => { }) }) +describe('npm release workflows', () => { + it('keeps publication dispatch-only and pack in the PR workflow', () => { + // pack stays in the PR/master release workflows so a PR proves the set packs. + for (const file of ['release.yml', 'release-vendor.yml']) { + const workflow = loadWorkflow(`.github/workflows/${file}`) + if (!isRecord(workflow.jobs)) throw new TypeError(`${file} must define jobs`) + expect(Object.keys(workflow.jobs).sort()).toEqual(['pack']) + } + + // publication is workflow_dispatch-only (never a PR check) and keeps the + // npm-publish environment plus the shared dist-tag group. + for (const file of ['release-publish.yml', 'release-vendor-publish.yml']) { + const workflow = loadWorkflow(`.github/workflows/${file}`) + if (!isRecord(workflow.on) || !isRecord(workflow.jobs)) throw new TypeError(`${file} must define on and jobs`) + expect(Object.keys(workflow.on)).toEqual(['workflow_dispatch']) + const publish = workflow.jobs.publish + if (!isRecord(publish)) throw new TypeError(`${file} must define a publish job`) + expect(publish.environment).toBe('npm-publish') + expect(publish.concurrency).toMatchObject({ group: 'Release-publish' }) + } + }) +}) + describe('Git hooks', () => { it('leaves frozen Agent Note sidecars to the archive verifier', () => { const lefthook = loadWorkflow('lefthook.yml') From 9616790b6cc6623aa4663f550e920cf6989664c7 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Thu, 20 Aug 2026 15:27:58 +0800 Subject: [PATCH 07/34] feat(web): answer ask_user_question over multiple lines A question that carried options collected its free-text answer in a single-line input: a long sentence scrolled sideways inside one line and Shift+Enter was inert, so an answer with structure could not be typed. The optionless question already used a textarea, but a fixed 64-140px box that never followed the draft. Both shapes now answer into one AnswerField: a `textarea rows=1` sharing a grid cell with a hidden mirror that renders the draft plus a trailing newline and so owns the height. Soft wraps are invisible to a '\n' count, so the mirror is what grows the box; growth stops at eight lines and the textarea scrolls from there, keeping the choices the answer belongs to in view. Enter still continues and submits, Shift+Enter breaks the line, and the IME guard is unchanged. --- ...-multiline-question-answer-field.i18n.yaml | 6 ++ ...6-08-20-multiline-question-answer-field.md | 43 ++++++++++ ...8-20-multiline-question-answer-field.zh.md | 43 ++++++++++ apps/web/tests/question-composer.e2e.ts | 32 +++++++- .../client/ui-user-questions/README.i18n.yaml | 4 +- packages/client/ui-user-questions/README.md | 2 +- .../client/ui-user-questions/README.zh.md | 2 +- .../src/client/QuestionComposer.module.css | 75 +++++++++++------- .../src/client/QuestionComposer.tsx | 78 +++++++++++++++---- .../user-questions-composer.client.spec.tsx | 37 +++++++++ 10 files changed, 278 insertions(+), 44 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md create mode 100644 .agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.zh.md diff --git a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.i18n.yaml new file mode 100644 index 0000000000..2040f0c481 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.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-08-20-multiline-question-answer-field.md +2026-08-20-multiline-question-answer-field.md: dbe146f1cd56b0f757784283269ba5061c425366 +2026-08-20-multiline-question-answer-field.zh.md: 7adb5bb37bf4393a5be21f1dc56b2be46fbfe9cb diff --git a/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md new file mode 100644 index 0000000000..dbe146f1cd --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-20-multiline-question-answer-field.md @@ -0,0 +1,43 @@ +# Agent Note: Multi-line answers in the question composer + +Status: implemented + +English | [中文](2026-08-20-multiline-question-answer-field.zh.md) + +## Problem + +`ask_user_question` offers a free-text answer beside the model's own options. On a question that carried options, that answer was a single-line ``: a long sentence scrolled sideways inside one 24px line, Shift+Enter did nothing, and an answer with structure — two requirements, a short list, a paragraph — could not be typed at all. The optionless question already used a textarea, but a fixed 64–140px box that neither followed the draft nor opened wider. + +The chat composer next to it grows with the draft and takes Shift+Enter as a newline. A user who has just typed a multi-line prompt there meets a field that silently flattens the same answer. + +## Decision + +Both question shapes answer into one `AnswerField`: a `