diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 340f07b15c..ef95863be1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: f397ca6a25cffa0ba709d0a8e84717a1c228b951 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: e1cde78bad16d618913e52894eff1271dac74be1 +2026-07-24-web-config-tree-boot-and-transport-layering.md: eb30ba84ef293a169931ef6519a9d6d2ea98af7f +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7a175b44fa8806b4b73eca538f32f16aa778fdee diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index f397ca6a25..eb30ba84ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `WebServer` provides `ctx.webServer` (`register(route) → disposer` with duplicate-pattern throw, `renderIndex` rendering — structured `webserver/index-inject` rows, then raw `tapIndex` transforms in registration order — and `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleRegistry`, providing `ctx.clientModules`) owns incremental package scanning, the bundle route, the boot injection rows, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index e1cde78bad..7a175b44fa 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,7 +18,7 @@ Status: implemented **每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`WebServer` provide `ctx.webServer`(`register(route) → disposer`、重复 pattern 即抛、`renderIndex` 渲染——先结构化 `webserver/index-inject` 行、后原始 `tapIndex` 按注册序应用——与 `port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleRegistry`,provide `ctx.clientModules`)拥有单包增量扫描、bundle 路由、启动注入行与 `onRebuilt`/`onGraphChanged` 通知。HMR(热模块替换) node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.i18n.yaml new file mode 100644 index 0000000000..90d1f96dff --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.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/architecture/2026-08-19-web-index-injection-table.md +2026-08-19-web-index-injection-table.md: 9ed02aa94cd318d107a32802d8723652e6b10ea2 +2026-08-19-web-index-injection-table.zh.md: 8ad036766faa14071b20da12ef907ab012cae23f diff --git a/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md new file mode 100644 index 0000000000..9ed02aa94c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-19-web-index-injection-table.md @@ -0,0 +1,30 @@ +# Agent Note: structured index injection table (webserver/index-inject) + +Status: implemented + +English | [中文](2026-08-19-web-index-injection-table.zh.md) + +## Problem + +The web shell's boot HTML needs three kinds of injection: client-modules' boot protocol (the `__ModuleLoader__` registration queue inline script, the parser-blocking preload `` +})()` const preload = PARSER_PRELOAD_IDS.map(id => graph.entries.find(entry => entry.id === id)) .filter((entry): entry is WebBootEntry => entry !== undefined) - .map(entry => ``) - .join('') - const script = `${queue}${preload}` - const head = html.indexOf('') - if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` - // Headless fixture pages may lack ; prepending keeps the read-before-shell ordering. - return `${script}${html}` + .map((entry): IndexInjection => ({ kind: 'script-src', placement: 'head', src: entry.url })) + return [ + { kind: 'script', placement: 'head', text: queue }, + ...preload, + { kind: 'global', name: '__DSH_BOOT__', value: graph }, + ] } /** * The web plugin table service: incremental `dsh.client` scan + wire composition - * + bundle route + index tap. Construction runs the activation scan + * + bundle route + index injection rows. Construction runs the activation scan * synchronously — a malformed declaration or missing bundle among the * already-loaded entries aggregates into one loud throw (FAILED fiber; the * boot activation audit reports it). @@ -353,10 +340,9 @@ export class ClientModuleRegistry extends Service { () => ctx.webServer.register({ kind: 'prefix', path: '/plugins', handler: this.serveBundle }), 'client-modules: bundle route', ) - ctx.effect( - () => ctx.webServer.tapIndex(html => injectBootManifest(html, this.composed)), - 'client-modules: boot manifest injection', - ) + ctx.on('webserver/index-inject', (table) => { + table.push(...bootInjections(this.composed)) + }) } /** diff --git a/packages/client/modules/tests/node-half.client.spec.ts b/packages/client/modules/tests/node-half.client.spec.ts index 0dc6ea0e3c..8577d2a57d 100644 --- a/packages/client/modules/tests/node-half.client.spec.ts +++ b/packages/client/modules/tests/node-half.client.spec.ts @@ -8,9 +8,9 @@ import { pathToFileURL } from 'node:url' import { runInNewContext } from 'node:vm' import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' -import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' +import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver' import * as modulesClient from '../src/client/index.ts' -import { ClientModuleRegistry, injectBootManifest, orderByModuleGraph } from '../src/index.ts' +import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts' import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts' const MODULES_ID = '@deepseek-ai/dsh-client-modules' @@ -81,9 +81,12 @@ function construct(packageNames: string[]): ClientModuleRegistry { return constructWithRoute(packageNames).service } -/** Execute the exact first inline script emitted by the Host HTML transform. */ +/** Execute the exact first inline script emitted by the Host boot rows. */ function injectedFacade(graph: WebBootGraph): { html: string; target: ClientModuleLoaderTarget } { - const html = injectBootManifest('', graph) + const html = renderIndexInjections( + '', + bootInjections(graph), + ) const source = /') const runtimeAt = html.indexOf('') - const graphAt = html.indexOf('window.__DSH_BOOT__ = ') + const graphAt = html.indexOf('globalThis["__DSH_BOOT__"] = ') const entryAt = html.indexOf('') expect([facadeAt, modulesAt, runtimeAt, graphAt, entryAt]).toEqual([...new Set([ facadeAt, modulesAt, runtimeAt, graphAt, entryAt, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 74a73ffb4a..44c63ba33c 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: 71dafd1c4d683f2d6da8fefd88aec81d56149481 -README.zh.md: e4d4ab990c74229bd2e077b575e7c87bb648ade9 +README.md: a90d1d9dd8156fc1063dc7358374f8b1715deb1a +README.zh.md: acab41ad8c95c9e24b39fc875f3047f429f3add2 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 71dafd1c4d..a90d1d9dd8 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 e4d4ab990c..acab41ad8c 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/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-conversation/src/client/chat/StatsLine.tsx b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx index 147d2b7c6c..2d9d14483b 100644 --- a/packages/client/ui-conversation/src/client/chat/StatsLine.tsx +++ b/packages/client/ui-conversation/src/client/chat/StatsLine.tsx @@ -101,16 +101,64 @@ 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 +} + /** - * 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 { +export function cacheHitPercent(usage: TokenUsageProjection): string | null { const denominator = billedInputTokens(usage) - return denominator === 0 - ? null - : Math.round(usage.cacheReadTokens / denominator * 100) + if (denominator === 0) return null + const missedInputTokens = usage.uncachedInputTokens + usage.cacheWriteTokens + if (missedInputTokens === 0) return '100' + + 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 + } + 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 01be6e0850..be55ee0579 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,43 @@ 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.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), + 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 +273,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', () => { diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 06ca10e568..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: a675b3cd0aa9e09e243b69065110e1d2b67ff1d9 -README.zh.md: aa67993ec879635fc0677501665d2e23de996dcf +README.md: 7822a5d41e8125752b8bc28fea3db2232323fd81 +README.zh.md: 5a78e683f3bb94f8b9d3a64b0d4ba1f3f3df9d85 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a675b3cd0a..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)). `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 aa67993ec8..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))。`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 b62e66e86e..f8d45cd4f6 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -177,12 +177,51 @@ 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 { + 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..de713858cf 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,23 @@ 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 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/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..c105766edf --- /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..c105766edf --- /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..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 6a669ffe2f..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 @@
-
+
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/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index eb7d3f176c..dd969c69f2 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -57,6 +57,7 @@ "devDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", @@ -65,10 +66,9 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/react": "~18.3.1", - "react": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-client-connection": "workspace:^" + "@types/react": "~18.3.1", + "react": "^18.2.0" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/src/boot-theme.ts b/packages/client/ui-theme/src/boot-theme.ts index 89b81b9de1..e6430cc6b9 100644 --- a/packages/client/ui-theme/src/boot-theme.ts +++ b/packages/client/ui-theme/src/boot-theme.ts @@ -1,15 +1,16 @@ /** - * Host-rendered theme bootstrap for the browser's pre-plugin interval. Each - * index response embeds the current durable built-in preference; the browser - * resolves only `system`, then writes the same DOM fields ui-layout's - * ThemePresenter owns after the client plugin tree activates. + * Theme bootstrap row for the browser's pre-plugin interval. Each index + * render embeds the current durable built-in preference; the browser resolves + * only `system`, then writes the same DOM fields ui-layout's ThemePresenter + * owns after the client plugin tree activates. */ +import type { IndexInjection } from '@deepseek-ai/dsh-host-webserver' import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' -/** Build the inline script for one schema-validated built-in preference. */ +/** Build the inline script body for one schema-validated built-in preference. */ function bootThemeScript(preference: ThemePreference): string { - return `` +})()` } /** - * Insert the theme bootstrap immediately after the opening body tag, before - * the shell mount and module script. Body-less fragments receive it at the - * end, where the HTML parser has already synthesized a body. - * @param html - Raw application index HTML. + * The theme bootstrap as an injection row: an inline script immediately after + * the opening body tag, before the shell mount and module script. * @param preference - Current Host-backed built-in preference. - * @returns HTML containing the theme bootstrap. + * @returns the body script row. */ -export function injectBootTheme( - html: string, +export function bootThemeInjection( preference: ThemePreference = DEFAULT_PREFERENCE, -): string { - const script = bootThemeScript(preference) - const body = /]*)?>/i.exec(html) - if (body === null) return `${html}${script}` - const at = body.index + body[0].length - return `${html.slice(0, at)}${script}${html.slice(at)}` +): IndexInjection { + return { kind: 'script', placement: 'body', text: bootThemeScript(preference) } } diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index c56c030e17..cc119d1825 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-host-webserver' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { injectBootTheme } from './boot-theme.ts' +import { bootThemeInjection } from './boot-theme.ts' import { DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, type ThemePreference, type ThemeSettings, @@ -26,18 +26,16 @@ function readPreference(ctx: Context): ThemePreference { } /** - * Register the durable theme section and initial-theme index transform when - * their optional Host services are composed. - * @param ctx - Host context that may acquire settings and HTTP services. + * Register the durable theme section when the optional settings service is + * composed, and answer every index injection collection with the current + * theme bootstrap row. + * @param ctx - Host context that may acquire the settings service. */ export function apply(ctx: Context): void { ctx.inject(['settings'], (settingsCtx) => { settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema) }) - ctx.inject(['webServer'], (httpCtx) => { - httpCtx.effect( - () => httpCtx.webServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))), - 'client-ui-theme: initial theme bootstrap', - ) + ctx.on('webserver/index-inject', (table) => { + table.push(bootThemeInjection(readPreference(ctx))) }) } diff --git a/packages/client/ui-theme/tests/boot-theme.client.spec.ts b/packages/client/ui-theme/tests/boot-theme.client.spec.ts index a8d0dff235..ab737059c1 100644 --- a/packages/client/ui-theme/tests/boot-theme.client.spec.ts +++ b/packages/client/ui-theme/tests/boot-theme.client.spec.ts @@ -1,8 +1,8 @@ // @vitest-environment jsdom -/** Host index injection and the resulting pre-plugin browser theme. */ +/** The theme bootstrap injection row and the resulting pre-plugin browser theme. */ import { runInNewContext } from 'node:vm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { injectBootTheme } from '../src/boot-theme.ts' +import { bootThemeInjection } from '../src/boot-theme.ts' import type { ThemePreference } from '../src/theme-settings.ts' const DARK_ATTRIBUTE = 'data-ds-dark-theme' @@ -11,15 +11,10 @@ function mockSystemDark(matches: boolean): void { vi.stubGlobal('matchMedia', vi.fn(() => ({ matches }) as MediaQueryList)) } -function executeBootstrap( - preference?: ThemePreference, - html = '
', -): string { - const injected = injectBootTheme(html, preference) - const source = /` } + } + case 'script': + return { placement: row.placement, markup: `` } + case 'script-src': + return { placement: row.placement, markup: `` } + case 'style': + return { placement: 'head', markup: `` } + case 'html': + return { placement: row.placement, markup: row.html } + default: + return assertNever(row) + } +} + +/** Insert `markup` into `html` at `at`. */ +function splice(html: string, at: number, markup: string): string { + return `${html.slice(0, at)}${markup}${html.slice(at)}` +} + +/** + * Render rows into an index.html body: head rows immediately after the + * opening head tag, body rows immediately after the opening body tag, each + * group in table order. + * @param html - the raw index.html body. + * @param rows - the collected injection table. + * @returns the html with every row rendered. + */ +export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string { + let head = '' + let body = '' + for (const row of rows) { + const rendered = renderRow(row) + if (rendered.placement === 'head') head += rendered.markup + else body += rendered.markup + } + let out = html + if (head !== '') { + const open = /]*)?>/i.exec(out) + // Headless fixture pages may lack ; prepending keeps the rows ahead + // of every document script. + out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head) + } + if (body !== '') { + const open = /]*)?>/i.exec(out) + // Body-less fragments receive the rows at the end, where the HTML parser + // has already synthesized a body. + out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body) + } + return out +} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 2cbd285856..ffe5b4648d 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import HttpServer from '../src/index.ts' +import HttpServer, { renderIndexInjections } from '../src/index.ts' let root: string | undefined let context: Context | undefined @@ -200,6 +200,54 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) + it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const server = loaded.webServer + let flag = 'dark' + loaded.on('webserver/index-inject', (table) => { + table.push( + { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, + { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, + { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } }, + { kind: 'style', text: 'body{margin:0}' }, + { kind: 'html', placement: 'head', html: '' }, + { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` }, + ) + }) + + const html = server.renderIndex('shell') + // Head rows land right after the opening head tag in table order; the body + // row lands right after the opening body tag. + const order = [ + '', + '', + '', + 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}', + '', + '', + '', + '', + 'shell', + ].map(part => html.indexOf(part)) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every(at => at !== -1)).toBe(true) + + // Fresh collection per render: the listener reads live state at emit time. + flag = 'light' + expect(server.renderIndex('')).toContain('window.__P__="light"') + + // Raw taps still run, over the already-rendered rows. + const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) + expect(server.renderIndex('')).toContain('window.__Q__=2') + untap() + + // Tag-less fragments: head rows prepend, body rows append. + expect(renderIndexInjections('
x
', [ + { kind: 'script', placement: 'head', text: 'H' }, + { kind: 'script', placement: 'body', text: 'B' }, + ])).toBe('
x
') + }) + it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { const first = await loadComposition() const takenPort = first.webServer.port diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 721c9987b6..ac4535bc1b 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -417,24 +417,63 @@ 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 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 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() + // Keep the subscription-type gates: issue-lifecycle does not re-subscribe + // ready_for_review (issue-policy owns that) and only reacts to submitted + // review events. 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') - 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') }}", - ) + 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') expect(policyPullRequest.types).toContain('ready_for_review') }) }) +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') diff --git a/scripts/client-build-environment.client.spec.ts b/scripts/client-build-environment.client.spec.ts index cce5cff2a0..e2eb0ebe72 100644 --- a/scripts/client-build-environment.client.spec.ts +++ b/scripts/client-build-environment.client.spec.ts @@ -26,6 +26,7 @@ const dshBuildWorkflows = [ 'e2b-e2e.yml', 'e2e.yml', 'release.yml', + 'release-publish.yml', 'sandbox.yml', ] diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index a088890413..4b1f71c5e9 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -184,6 +184,7 @@ export const EVENT_SCOPE_PAGE: Record = { 'system-prompt': 'system-prompt.md', 'session-telemetry': 'session-telemetry.md', 'tools': 'tools.md', + 'webserver': 'web-server.md', 'workflow': 'workflow.md', } @@ -480,6 +481,7 @@ export const LINK_MAP: Readonly> = { PresetSpec: 'permission-presets.md', InvariantInstaller: 'invariants.md', WebRoute: 'web-server.md', + IndexInjection: 'web-server.md', StorageBackend: 'storage.md', StorageForms: 'storage.md', Domain: 'storage.md', diff --git a/tsconfig.host.json b/tsconfig.host.json index 601c74c247..95036f727c 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",