diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml index b478070862..394f0b82e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.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-30-followup-enqueue-and-owned-runs.md -2026-07-30-followup-enqueue-and-owned-runs.md: e0561f8cfc975dd60e69307b160070c292efff8e -2026-07-30-followup-enqueue-and-owned-runs.zh.md: a807b3ebe47399cae95c38bc2232e20e00e839f5 +2026-07-30-followup-enqueue-and-owned-runs.md: 9978b3a8ab8678fe98e000476505cee9dcaa1bc6 +2026-07-30-followup-enqueue-and-owned-runs.zh.md: 6501266d32b5ae884a478bfae9e4f1e3d5d85575 diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md index e0561f8cfc..9978b3a8ab 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md @@ -16,7 +16,7 @@ Keep `Agent.followup(message): void` as an enqueue-only operation. `Agent.whenId The low-level SDK protocol answers `session/prompt` as soon as enqueue succeeds with `{ messageId }`. It streams durable facts through `session.event`, publishes whole-agent transitions through `session.status`, and has no `session.finished`. A low-level client may observe that receipt and later idleness, but receives no prompt result. -High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their `finalResponse` is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run. +High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their final response is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The Python SDK also reports the last root turn's reason kind as the run-level [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.md), without attributing it to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run. ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic `end_turn`. Token-limit endings are not attributed to the prompt: they settle as `end_turn`. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as `cancelled` at idle alongside explicit ACP cancellation or disposal. @@ -33,10 +33,10 @@ Goal continuation retains `MessageId` only to recognize its durable queued and a ## Verification - Agent and inbox tests pin enqueue-only follow-up, durable admission or cancellation, and whole-agent idle observation. -- SDK protocol, TypeScript SDK, and Python SDK tests pin the `{ messageId }` receipt, `session.status`, the absence of `session.finished`, and receipt-to-idle `RunResult` collection without prompt-level `status` or `reason`. +- SDK protocol, TypeScript SDK, and Python SDK tests pin the `{ messageId }` receipt, `session.status`, the absence of `session.finished`, and receipt-to-idle `RunResult` collection without prompt-level `status` or `reason`; Python SDK tests separately pin its run-level `finish_reason` observation. - ACP, one-shot CLI, goal continuation, and subagent tests pin the distinct activity ownership each integration possesses. - Consumer tests pin that no production integration derives a follow-up result by correlating `MessageId` with `turn/end`. ## Consequences -An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications disappear from SDK and ACP results; callers that need those facts must inspect the durable event stream without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result. +An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from SDK and ACP results; callers may inspect run-level or durable event facts without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result. diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md index a807b3ebe4..6501266d32 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md @@ -16,7 +16,7 @@ Status: implemented 底层 SDK 协议在入队成功后立即以 `{ messageId }` 响应 `session/prompt`。它通过 `session.event` 流式传输持久事实,通过 `session.status` 发布整个 agent 的状态转换,且不包含 `session.finished`。底层客户端可以观察该回执和之后的 idle,但不会收到提示词结果。 -只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其 `finalResponse` 是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 +只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其最终响应是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。Python SDK 还把根会话最后一个轮次的结束原因 kind 作为运行级 [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.md) 返回,但不会将其归因于已提交的提示词。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层串行处理每个 ACP 会话中唯一一个正在处理的提示词,等待整个 agent 进入 idle,其他情况均报告通用的 `end_turn`。token 上限的轮次结束不归因于提示词:它们以 `end_turn` 结算。与该提示词关联的轮次上的模型错误会立即以该错误拒绝提示词(错误按其所属轮次归因),而 turnless 槽位(准入已丢弃提示词)会在 idle 时以 `cancelled` 结算,与显式 ACP 取消或 dispose(资源释放)并列。 @@ -33,10 +33,10 @@ Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal ## 验证 - Agent 与 inbox 测试固定 follow-up 仅入队、持久准入或取消以及整个 agent 的 idle 观测。 -- SDK 协议、TypeScript SDK 和 Python SDK 测试固定 `{ messageId }` 回执、`session.status`、不存在 `session.finished`,以及不含提示词级 `status` 或 `reason` 的回执到 idle `RunResult` 收集。 +- SDK 协议、TypeScript SDK 和 Python SDK 测试固定 `{ messageId }` 回执、`session.status`、不存在 `session.finished`,以及不含提示词级 `status` 或 `reason` 的回执到 idle `RunResult` 收集;Python SDK 测试另行固定其运行级 `finish_reason` 观测。 - ACP、单次 CLI、goal 续行和 subagent 测试固定各集成实际拥有的不同活动边界。 - 消费方测试固定生产集成都不会通过关联 `MessageId` 与 `turn/end` 来推导 follow-up 结果。 ## 后果 -自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应和事件有意比初始消息涵盖更广。SDK 和 ACP 结果不再包含提示词级模型错误和 token 上限分类;需要这些事实的调用方必须检查持久事件流,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。 +自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应、结束原因和事件有意比初始消息涵盖更广。SDK 和 ACP 结果仍不包含提示词级模型错误和 token 上限分类;调用方可以检查运行级或持久事件事实,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.i18n.yaml new file mode 100644 index 0000000000..13d341ab90 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.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/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md +2026-08-10-pre-plugin-theme-bootstrap.md: 276d564dcef60e5130b4b2d0ef7cbc12eeb8b6d6 +2026-08-10-pre-plugin-theme-bootstrap.zh.md: 7162b2d851b50f75b3af6320ad9348383933e50f diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md new file mode 100644 index 0000000000..276d564dce --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.md @@ -0,0 +1,37 @@ +# Agent Note: Pre-Plugin Theme Bootstrap + +Status: implemented + +English | [中文](2026-08-10-pre-plugin-theme-bootstrap.zh.md) + +## Problem + +The web shell renders `Loading plugins…` before the browser-side plugin tree activates. The theme tokens are already loaded with the shell styles, but `color-scheme` and `body[data-ds-dark-theme]` are not written until ui-theme's ThemeService and ui-layout's ThemePresenter activate; with a persisted dark preference, the loading page therefore renders first with the light palette and then switches to dark. + +`dshClient.immediately` only includes the bundle in first-stage prefetching; it does not cause the plugin to execute before HTML parsing or the shell's initial render. Changing only the client plugin's loading tier cannot close this window. + +## Decision + +ui-theme's host half transforms each index HTML document through `ctx.httpServer.tapIndex()`, inserting a synchronous inline script immediately after the opening `` tag. The transform registers under an optional `httpServer` injection, so compositions without that service still activate ui-theme and install no transform. When the HTML parser executes the script, the body exists, but the shell's module script and React root have not yet run. + +The host half registers the [`ui-theme.preference` settings section](2026-08-06-host-backed-web-preferences.md) when a settings provider exists. For each index response, it embeds that schema-validated built-in preference in the inline script; without a settings provider or active registration, it embeds the `system` default. The browser resolves `system` through `prefers-color-scheme`, falling back to light when `matchMedia` is unavailable. It writes only the two pieces of DOM state that ThemePresenter later owns: `document.documentElement.style.colorScheme` and `body[data-ds-dark-theme]`. + +The bootstrap logic recognizes only the built-in `light`, `dark`, and `system` semantics. It registers no listeners and does not resolve third-party themes or token overrides. After the browser-side plugin tree activates, ThemeService remains authoritative for theme state, and ThemePresenter writes the complete resolved result back to the same DOM state and owns subsequent updates and disposal. + +## Verification + +ui-theme's unit tests cover activation without either optional Host service, the script position, Host-setting precedence, the OS preference, missing `matchMedia`, input without a body, live settings reads, and disposal of the Host registrations with the plugin fiber. A Chromium scenario for the real web composition selects the durable dark preference, holds the plugin bundle request open to keep the loading page observable, then asserts that the index response produces a dark background, the body attribute, and the root element's `color-scheme`. The change does not alter the accessibility tree, so it produces no new page golden. + +## Alternatives considered + +**Hard-code the logic in `apps/web/index.html`.** This would run at the same point, but static HTML cannot embed the current Host setting and would duplicate the preference resolution and DOM fields owned by ui-theme. The Host transform follows the theme plugin's lifecycle and keeps the application shell unaware of the theme domain. + +**Make the ui-theme client bundle synchronous or activate it earlier.** `immediately` controls only prefetching; plugin instantiation still occurs after the shell starts running. Blocking the initial render until ThemeService activates would delay the visible loading and error screens and make the shell depend on the plugin tree it monitors to render failures. + +**Rely only on CSS `prefers-color-scheme`.** Media queries cannot read an explicit persisted choice, so a user who selects dark while the operating system uses light would still see a flash. + +**Run in `` and add a temporary class to html.** The body does not exist yet, and this would require a set of temporary selectors separate from the final palette attributes. Immediately after `` is the earliest parse position that can write the final DOM fields directly. + +## Consequences + +The loading page's first frame matches the durable built-in preference and defaults to the OS preference when no settings provider is composed. The index transform reads Host settings for every response, while the inline script contains only the selected built-in value and `system` resolution. Changes to the built-in preference semantics or ThemePresenter DOM fields must update both the script and ThemeService. A custom theme still applies fully only after the browser plugins activate; during the loading interval, the page uses the light or dark base palette to which that theme resolves. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.zh.md new file mode 100644 index 0000000000..7162b2d851 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-pre-plugin-theme-bootstrap.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 插件激活前的主题引导 + +Status: implemented + +[English](2026-08-10-pre-plugin-theme-bootstrap.md) | 中文 + +## Problem + +Web 壳在浏览器侧插件树激活前呈现 `Loading plugins…`。主题 token 已随壳样式加载,但 `color-scheme` 和 `body[data-ds-dark-theme]` 要等 ui-theme 的 ThemeService 与 ui-layout 的 ThemePresenter 激活后才写入;持久化偏好为深色时,加载页因此先按浅色调色板绘制,再切为深色。 + +`dshClient.immediately` 只把 bundle 纳入第一阶段预取,不会让插件在 HTML 解析或壳首次渲染前执行。仅调整客户端插件的加载档位无法关闭这段时间窗口。 + +## Decision + +ui-theme 的主机侧通过 `ctx.httpServer.tapIndex()` 转换每份 index HTML,在 `` 起始标签后紧接一段同步内联脚本。该转换通过可选的 `httpServer` 注入注册,因此不含该服务的组合仍会激活 ui-theme,但不会安装转换。HTML 解析器执行该脚本时,body 已存在,而壳的模块脚本与 React 根节点尚未运行。 + +settings provider 存在时,主机侧会注册 [`ui-theme.preference` settings 分节](2026-08-06-host-backed-web-preferences.md)。它为每份 index 响应把经过 schema 校验的内建偏好嵌入内联脚本;不存在 settings provider 或有效注册时则嵌入默认值 `system`。浏览器通过 `prefers-color-scheme` 解析 `system`,不支持 `matchMedia` 时回退为浅色。脚本只写 ThemePresenter 后续拥有的两项 DOM 状态:`document.documentElement.style.colorScheme` 与 `body[data-ds-dark-theme]`。 + +引导逻辑只认识内建的 `light`、`dark`、`system` 语义,不注册监听器,也不解析第三方主题或 token 覆盖。浏览器侧插件树激活后,ThemeService 仍是主题状态的权威来源,ThemePresenter 会把完整解析结果重新写入同一组 DOM 状态并负责后续更新与释放。 + +## Verification + +ui-theme 的单元测试覆盖不含任一可选 Host 服务时的激活、脚本位置、Host 设置优先级、系统偏好、缺少 `matchMedia`、不含 body 的输入、实时读取 settings,以及 Host 注册随插件 fiber 一同释放。真实 Web 组合的 Chromium 场景会选择持久化深色偏好并拦住插件 bundle 请求,使加载页保持可观察,再断言 index 响应产生了深色背景、body 属性和根元素 `color-scheme`。该变化不改变可访问性树,因此不产生新的页面 golden。 + +## Alternatives considered + +**把逻辑固定写进 `apps/web/index.html`。** 这样能在相同时机执行,但静态 HTML 无法嵌入当前 Host 设置,还会复制 ui-theme 拥有的偏好解析和 DOM 字段;Host 转换会跟随主题插件的生命周期,并让应用壳无需了解主题领域。 + +**让 ui-theme 客户端 bundle 同步或更早激活。** `immediately` 只控制预取,插件实例化仍发生在壳开始运行之后;把首次渲染阻塞到 ThemeService 激活会延后可见的加载与报错界面,也会让壳的故障呈现依赖被它监测的插件树。 + +**只依赖 `prefers-color-scheme` 的 CSS。** 媒体查询无法读取显式持久化选择,因此操作系统为浅色而用户选择深色时仍会闪烁。 + +**在 `` 中执行并给 html 添加临时类。** body 此时尚不存在,还需要一套与正式调色板属性不同的临时选择器。紧接 `` 是能够直接写正式 DOM 字段的最早解析位置。 + +## Consequences + +加载页首帧与持久化内建偏好一致;未组合 settings provider 时则默认采用系统偏好。index 转换会为每份响应读取 Host settings,而内联脚本只包含选定的内建值与 `system` 解析逻辑。内建偏好语义或 ThemePresenter DOM 字段变化时,必须同时更新脚本与 ThemeService。自定义主题仍会在浏览器插件激活后才完整应用;加载期间,页面使用该主题解析后的浅色或深色基础调色板。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.i18n.yaml new file mode 100644 index 0000000000..24a8c8568e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.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/bug-fix/2026-08-11-owned-run-finish-reason.md +2026-08-11-owned-run-finish-reason.md: 87a158c7fedc8c6447306c489ba9f969d95dc412 +2026-08-11-owned-run-finish-reason.zh.md: d75b7d4f0c932ec2d3665b8911835e3b081af129 diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.md b/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.md new file mode 100644 index 0000000000..87a158c7fe --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.md @@ -0,0 +1,33 @@ +# Agent Note: Owned-run finish reason reporting + +Status: implemented + +English | [中文](2026-08-11-owned-run-finish-reason.zh.md) + +## Problem + +Python SDK consumers need a concise classification of how an owned activity interval reached idle. Requiring each consumer to scan raw `turn/end` events duplicates protocol knowledge, while a generic success status loses token-limit and model-error distinctions. + +## Decision + +`RunResult.finish_reason` is the string `kind` from the last root-session `turn/end` collected between the submitted message's durable inbox receipt and the next whole-agent idle. It is `None` when the interval contains no `turn/end`. A `turn/end` without a string `data.reason.kind` raises `SdkProtocolError` instead of being reported as an interval without a turn ending. The field describes the owned run interval; it does not assign that ending to the submitted prompt. The [owned-run boundary decision](../architecture/2026-07-30-followup-enqueue-and-owned-runs.md) continues to prohibit prompt-level result attribution. + +The field exposes only the kind because callers need a stable classification and the complete structured reason remains available in `RunResult.events`. Transport loss, timeout, and protocol failures still raise instead of producing a finish reason. + +## Alternatives considered + +**Restore `status`.** A deployment-mapped `ok` or `error` status conflates distinct durable endings and resembles transport success, so it does not answer why the interval finished. + +**Expose a model `FinishReason`.** A run may contain multiple model steps, and intermediate `tool-calls` endings do not finish the run. The agent's last `turn/end` is the relevant run-level observation. + +**Call the field `stop_reason`.** ACP and subagent seams map turn-ending reasons into their own `stopReason` value sets. The Python field preserves the raw agent reason kind, so sharing their name would imply a mapping this interface does not perform. + +**Expose the complete structured turn reason.** The raw event stream already preserves error and cancellation details. Duplicating that object on `RunResult` would create two representations that Python callers must reconcile. + +## Verification + +Python SDK tests cover selection of the last turn ending, an interval without a turn ending, and rejection of a malformed turn-ending reason. The SDK README documents the field's values, `None` case, failure behavior, and run-level scope. + +## Consequences + +Callers can branch on `completed`, `max-tokens`, `error`, and future reason kinds without parsing the event list. The field may describe steering, injected context, or queued work that joined the interval, so it must not be presented as the initiating prompt's causal outcome. The in-repo TypeScript SDK exposes the finish-reason observation only through its typed events; its callers can read it directly from `SessionEvent[]`. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.zh.md new file mode 100644 index 0000000000..d75b7d4f0c --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-owned-run-finish-reason.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 自有运行的结束原因报告 + +Status: implemented + +[English](2026-08-11-owned-run-finish-reason.md) | 中文 + +## 问题 + +Python SDK 消费方需要简洁地判断自有活动区间如何进入 idle。要求每个消费方扫描原始 `turn/end` 事件会重复协议知识,而通用的成功状态会丢失 token 上限与模型错误之间的区别。 + +## 决策 + +`RunResult.finish_reason` 是从已提交消息进入持久 inbox 的回执开始、到整个 agent 下一次进入 idle 为止所收集的根会话最后一个 `turn/end` 的字符串 `kind`。如果该区间没有 `turn/end`,字段为 `None`。缺少字符串 `data.reason.kind` 的 `turn/end` 会抛出 `SdkProtocolError`,而不会报告为区间内没有轮次结束。该字段描述自有运行区间;它不会把这个结束原因归属于已提交的提示词。[自有运行边界决策](../architecture/2026-07-30-followup-enqueue-and-owned-runs.md)仍禁止提示词级结果归因。 + +该字段只公开 kind,因为调用方需要稳定的分类,完整的结构化原因仍可从 `RunResult.events` 取得。传输丢失、超时和协议故障仍会抛出异常,而不会生成结束原因。 + +## 考虑过的替代方案 + +**恢复 `status`。** 由部署映射的 `ok` 或 `error` 状态会混淆不同的持久结束情况,而且看起来像传输成功状态,因此无法回答区间为何结束。 + +**公开模型 `FinishReason`。** 一次运行可能包含多个模型步骤,中间的 `tool-calls` 结束并不代表运行结束。agent 最后一个 `turn/end` 才是相关的运行级观测。 + +**将字段命名为 `stop_reason`。** ACP 和 subagent seam 会把轮次结束原因映射到各自的 `stopReason` 取值集合。Python 字段保留原始的 agent 原因 kind,因此沿用它们的名称会让人误以为该接口也执行了这种映射。 + +**公开完整的结构化轮次原因。** 原始事件流已经保留错误与取消的详细信息。在 `RunResult` 上复制这个对象会产生两种需要 Python 调用方协调的表示。 + +## 验证 + +Python SDK 测试覆盖选择最后一个轮次结束、区间内没有轮次结束,以及拒绝畸形轮次结束原因。SDK README 记录字段取值、`None` 情况、失败行为和运行级范围。 + +## 后果 + +调用方无需解析事件列表,即可按 `completed`、`max-tokens`、`error` 和未来的原因 kind 分支。该字段可能描述区间内加入的 steering、注入上下文或排队工作,因此不能将其表述为初始提示词的因果结果。仓库内的 TypeScript SDK 只通过类型化事件提供结束原因观测;其调用方可以直接从 `SessionEvent[]` 读取该观测。 diff --git a/apps/web/tests/settings-chrome.e2e.ts b/apps/web/tests/settings-chrome.e2e.ts index 974181450a..a3b387b660 100644 --- a/apps/web/tests/settings-chrome.e2e.ts +++ b/apps/web/tests/settings-chrome.e2e.ts @@ -152,6 +152,67 @@ describe('web e2e: settings modal and General preferences', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) + it('uses the persisted dark preference while plugins are still loading', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-boot-theme')) + await page.emulateMedia({ colorScheme: 'light' }) + await page.getByRole('button', { name: '设置', exact: true }).click() + const initialDialog = page.getByRole('dialog', { name: '设置' }) + const darkCube = initialDialog.getByRole('button', { name: '深色' }) + await darkCube.click() + await expect.poll(() => darkCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') + await expect.poll(async () => readFile(join(scaffold.harnessHome, 'settings.yaml'), 'utf8'), { timeout: 5_000 }) + .toMatch(/ui-theme:\n\s+preference: dark/) + await page.keyboard.press('Escape') + + // Hold real plugin bundles so the shell-owned loading page remains observable. + const pluginPattern = '**/plugins/**' + let releaseBundles = (): void => {} + const bundlesReleased = new Promise((resolve) => { releaseBundles = resolve }) + await page.route(pluginPattern, async (route) => { + await bundlesReleased + await route.continue() + }) + + const warningStart = tripwire.warnings.length + let reload: ReturnType | undefined + try { + reload = page.reload({ waitUntil: 'domcontentloaded' }) + const loading = page.getByText('Loading plugins…', { exact: true }) + await loading.waitFor({ timeout: 10_000 }) + const state = await loading.evaluate((element) => { + const boot = element.parentElement?.parentElement + if (boot === undefined || boot === null) throw new Error('loading hint is detached from the boot page') + return { + attr: document.body.hasAttribute('data-ds-dark-theme'), + background: getComputedStyle(boot).backgroundColor, + colorScheme: document.documentElement.style.colorScheme, + } + }) + expect(state).toEqual({ + attr: true, + background: 'rgb(21, 21, 23)', + colorScheme: 'dark', + }) + } finally { + releaseBundles() + await reload + await page.unroute(pluginPattern) + } + + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + acknowledgeReloadConnectionLoss(tripwire, warningStart) + await page.getByRole('button', { name: '设置', exact: true }).click() + const restoredDialog = page.getByRole('dialog', { name: '设置' }) + const systemCube = restoredDialog.getByRole('button', { name: '跟随系统' }) + await systemCube.click() + await expect.poll(() => systemCube.getAttribute('aria-pressed'), { timeout: 5_000 }).toBe('true') + await expect.poll(() => page.evaluate(() => document.body.hasAttribute('data-ds-dark-theme')), { + timeout: 5_000, + }).toBe(false) + await page.keyboard.press('Escape') + expect(tripwire.pageErrors).toEqual([]) + }, 90_000) + it('flips the theme through the Appearance cubes and persists across reload and a distinct port', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-settings-appearance')) interface ThemeState { diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index f14bf8cea2..3ef76cd969 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.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 docs/module-graph.md -module-graph.md: 59e22a8b82a210dd66f6e2186f0b827a541e00cc -module-graph.zh.md: 00a433ebaeabba4ce0e39919c3e0fe817608e718 +module-graph.md: 184237c2cc9753fb8add05615531b9832b0e22d1 +module-graph.zh.md: 0d58bbe9c8f82f8272163964bd902c0f60068e97 diff --git a/docs/module-graph.md b/docs/module-graph.md index 59e22a8b82..184237c2cc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -461,6 +461,7 @@ flowchart TD pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime @@ -1332,7 +1333,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 00a433ebae..0d58bbe9c8 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -463,6 +463,7 @@ flowchart TD pkg_client_ui_theme --> pkg_client_runtime pkg_client_ui_theme --> pkg_client_ui_primitives pkg_client_ui_theme --> pkg_client_ui_slots + pkg_client_ui_theme --> pkg_host_webserver pkg_client_ui_theme --> pkg_invariants pkg_client_ui_workspace --> pkg_client_locale pkg_client_ui_workspace --> pkg_client_runtime @@ -1334,7 +1335,7 @@ flowchart TD | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index e99389ac79..30de666f6a 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/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-theme/README.md -README.md: cab9961a6d703d600a856e71339cda7062d20d62 -README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739 +README.md: df4d5e0370962bf6f2a8ac0a7b88d669225dc5c5 +README.zh.md: e0f614645a16b44e374e450bb8dd1e3c5805ea42 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index cab9961a6d..df4d5e0370 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -4,6 +4,8 @@ English | [中文](README.zh.md) Theme plugin: ThemeService over the --dsw-* token base stylesheets (static scale + alias semantic layers). The service owns the live theme preference (`light`/`dark`/`system`), resolves `system` through `prefers-color-scheme`, and publishes immutable `ThemeSnapshot`s on the `theme/change` event; it never touches the DOM — ui-layout's presenter applies the resolved snapshot (`html { color-scheme }`, `body[data-ds-dark-theme]`, and inline alias tokens). A loopback browser provides the service immediately with `system`, then loads `ui-theme.preference` in the background and writes each built-in selection through the Host settings API, whose local provider stores it in `$DSH_HOME/settings.yaml` by default; pushed settings changes and reconnects refetch it, rapid selections are serialized in gesture order with namespace revisions, and a rejected latest write reloads the durable value. A remote browser cannot access the privileged settings API, so its selection remains process-local. Third-party registered theme ids remain an in-process extension and do not cross the built-in settings schema; removing one never overwrites the last durable built-in preference. The [Host-backed preferences decision](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md) owns the persistence boundary. +When the host composition includes an HTTP server, the host half injects a synchronous bootstrap immediately after the opening `` tag. Each index response embeds the registered Host setting for `ui-theme.preference`, or `system` when no settings provider is present; the browser resolves `system` from the OS scheme, then sets `color-scheme` and `body[data-ds-dark-theme]` before the shell loading page renders. Compositions without an HTTP server remain unaffected, and ThemeService and ui-layout remain authoritative for client state and subsequent DOM updates after the plugin tree activates. + `src/styles/` holds five sheets, all imported by the web shell's `base.css`: `base.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. `scrollbar.css` is the sole consumer of the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Scrollbar rebinding contract: `scrollbar.css` binds `--dsh-scrollbar-thumb` and `--dsh-scrollbar-thumb-hover` on `body` to the l1 (base-surface) tokens, and both rendering paths read that pair. An elevated surface (menu, popover, dialog) sets `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` and `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)` on its own container; one rebind retints whichever path the engine took. The pair's other legal target is `transparent`, which draws no thumb at all — [ui-sidebar](../ui-sidebar/README.md) rebinds its column that way while the pointer is elsewhere. A rebind to the l1 pair is not a rebind; it restates the base-surface default. diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index 81b64c3567..e0f614645a 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -4,6 +4,8 @@ 主题插件:基于 --dsw-* token 基础样式表(静态尺度 + 别名语义层)的 ThemeService。该服务拥有实时主题偏好(`light`/`dark`/`system`),将 `system` 通过 `prefers-color-scheme` 解析为实际主题,并发布不可变的 `ThemeSnapshot`,通过 `theme/change` 事件通知变化;它绝不接触 DOM:ui-layout 的呈现器会应用解析后的快照(`html { color-scheme }`、`body[data-ds-dark-theme]`,以及主题的别名 token 内联变量)。来自回环地址的浏览器会先以 `system` 立即提供该服务,随后在后台加载 `ui-theme.preference`,并将每次内置主题选择通过 Host settings API 写入;其本地提供方默认将设置存入 `$DSH_HOME/settings.yaml`。收到推送的 settings 变更时或重连后,浏览器都会重新拉取该设置;连续快速选择会按操作顺序携带 namespace revision 串行写入,最新写入被拒时则重新加载持久化值。远程浏览器无法访问特权 settings API,因此它的选择仅保留在进程内。已注册的第三方主题 id 仍是进程内扩展,不会跨越内置 settings schema;移除其中任意一个都绝不会覆盖最后一个持久化的内置偏好。该持久化边界由[Host settings 支撑的偏好决策](../../../.agents/notes/implemented/bug-fix/2026-08-06-host-backed-web-preferences.md)拥有。 +当主机组合包含 HTTP 服务器时,主机侧紧接 `` 起始标签注入同步引导代码。每份 index 响应会嵌入已注册的 Host 设置 `ui-theme.preference`,没有 settings provider 时则嵌入 `system`;浏览器按操作系统配色解析 `system`,随后在外壳加载页面渲染前设置 `color-scheme` 和 `body[data-ds-dark-theme]`。不含 HTTP 服务器的组合不受影响,插件树激活后,ThemeService 与 ui-layout 仍分别是客户端状态和后续 DOM 更新的权威来源。 + `src/styles/` 下有五张样式表,全部由 web 壳的 `base.css` 导入:`base.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。`scrollbar.css` 是 `--dsw-alias-scrollbar-*` token 的唯一消费方,必须排在声明这些 token 的 `design-platform.css` 之后。 滚动条重新绑定约定:`scrollbar.css` 在 `body` 上把 `--dsh-scrollbar-thumb` 与 `--dsh-scrollbar-thumb-hover` 绑定到 l1(基础表面)token,两条渲染路径都读取这一组变量。高层级表面(菜单、浮层、对话框)在自己的容器上设置 `--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2)` 与 `--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2)`;一次重新绑定即可为引擎实际走的那条路径换色。这组变量的另一个合法目标是 `transparent`,即完全不绘制滑块——[ui-sidebar](../ui-sidebar/README.md) 在指针不在栏内时就这样重新绑定自己的列。绑回 l1 那组不算重新绑定,它只是重述基础表面的默认值。 diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 99538ed6ec..1d2e1e6b19 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", - "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row", + "description": "Theme plugin: Host bootstrap for the pre-plugin palette; DOM-free ThemeService for light/dark/system state; --dsw-* token styles and Appearance settings row", "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" @@ -48,6 +48,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-theme/src/boot-theme.ts b/packages/client/ui-theme/src/boot-theme.ts new file mode 100644 index 0000000000..89b81b9de1 --- /dev/null +++ b/packages/client/ui-theme/src/boot-theme.ts @@ -0,0 +1,40 @@ +/** + * 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. + */ + +import { DEFAULT_PREFERENCE, type ThemePreference } from './theme-settings.ts' + +/** Build the inline script 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. + * @param preference - Current Host-backed built-in preference. + * @returns HTML containing the theme bootstrap. + */ +export function injectBootTheme( + html: string, + 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)}` +} diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 8b8e0c6d30..ce52f66395 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,23 +1,43 @@ -/** Host registration for the browser theme preference. */ +/** Host registration for the browser theme preference and pre-plugin palette. */ import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-host-webserver' import { settingsNamespace } from '@deepseek-ai/dsh-settings' -import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' +import { injectBootTheme } from './boot-theme.ts' +import { + DEFAULT_PREFERENCE, THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema, + type ThemePreference, type ThemeSettings, +} from './theme-settings.ts' export { DEFAULT_PREFERENCE, THEME_PREFERENCE_FIELD, THEME_PREFERENCES, THEME_SETTINGS_NAMESPACE, type ThemePreference, type ThemeSettings, } from './theme-settings.ts' +const THEME_NAMESPACE = settingsNamespace(THEME_SETTINGS_NAMESPACE) + +/** Read the registered preference or use the schema default without a settings provider. */ +function readPreference(ctx: Context): ThemePreference { + const settings = ctx.get('settings') + if (settings === undefined) return DEFAULT_PREFERENCE + const section = settings.get(THEME_NAMESPACE) as ThemeSettings | undefined + if (section === undefined) return DEFAULT_PREFERENCE + return section.preference +} + /** - * Register the durable theme section when a settings provider exists. - * @param ctx - Host context whose optional settings service owns the section. + * 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. */ export function apply(ctx: Context): void { ctx.inject(['settings'], (settingsCtx) => { - settingsCtx.settings.register( - settingsNamespace(THEME_SETTINGS_NAMESPACE), - ThemeSettingsSchema, + settingsCtx.settings.register(THEME_NAMESPACE, ThemeSettingsSchema) + }) + ctx.inject(['httpServer'], (httpCtx) => { + httpCtx.effect( + () => httpCtx.httpServer.tapIndex(html => injectBootTheme(html, readPreference(ctx))), + 'client-ui-theme: initial theme bootstrap', ) }) } diff --git a/packages/client/ui-theme/tests/boot-theme.spec.ts b/packages/client/ui-theme/tests/boot-theme.spec.ts new file mode 100644 index 0000000000..a8d0dff235 --- /dev/null +++ b/packages/client/ui-theme/tests/boot-theme.spec.ts @@ -0,0 +1,71 @@ +// @vitest-environment jsdom +/** Host index injection 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 type { ThemePreference } from '../src/theme-settings.ts' + +const DARK_ATTRIBUTE = 'data-ds-dark-theme' + +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 = /