Merge master and preserve parent-message settlement deduplication
This commit is contained in:
commit
21bd0789ec
362 files changed
+8170
-3006
No files matched your search
@@ -9,8 +9,8 @@
|
||||
en: c8b932b02a4e1c80
|
||||
zh: 96c466be1a521f3f
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision:
|
||||
en: b57eb0f268033ff1
|
||||
zh: 4143382a50181414
|
||||
en: 2e08d801099cbe18
|
||||
zh: 8c5ab162ada9cc41
|
||||
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology:
|
||||
en: 5ea3ddfae82df181
|
||||
zh: 2d05d95e51081696
|
||||
|
||||
@@ -32,7 +32,7 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov
|
||||
- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
|
||||
- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run.
|
||||
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
|
||||
|
||||
The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes.
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
|
||||
- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。
|
||||
- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。
|
||||
|
||||
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
|
||||
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
|
||||
|
||||
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-09-27-web-search-offered-by-provider-readiness.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready:
|
||||
en: 9163af91ea0e030d
|
||||
zh: 92e44043b999d32f
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem:
|
||||
en: 6fac3c73a4415c83
|
||||
zh: f65b61cc1705348b
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision:
|
||||
en: 458416ed7e47f644
|
||||
zh: 8b4dd820228e9dfd
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered:
|
||||
en: b731254983be656d
|
||||
zh: 5dee2b09f7faa537
|
||||
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences:
|
||||
en: 798faeaa0b406eac
|
||||
zh: 97bbfdaebc84b1d4
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Agent Note: Offer web_search and web_fetch only while the selected provider is ready
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed.
|
||||
|
||||
## Decision
|
||||
|
||||
Each role of the web capability seam owns one part of a per-step check:
|
||||
|
||||
- **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`.
|
||||
- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section.
|
||||
- **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set.
|
||||
|
||||
The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway.
|
||||
|
||||
Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none.
|
||||
|
||||
The request header records the assembled tools, so the agent loop announces each change through tool-registry updates. A route switch, a stored key, or a removed key changes the next request; DeepSeek routes load a returning tool as a deferred addition, and routes without tool-update support receive the complete tool list.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed.
|
||||
|
||||
**Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation.
|
||||
|
||||
**Read the route from the Session's latest `request/context` event instead of passing it.** That event records the previous request, so a new Session's first request and the first request after a model switch would be decided for the wrong route or for none.
|
||||
|
||||
**Register or unregister `web_search` in `dsh-tool-web`.** Registration is shared by every Session of an agent preset, while credentials depend on each request's route. Unregistering would also make `toolOrder` entries and subagent `toolFilter` restrictions that name `web_search` fail.
|
||||
|
||||
**Remove `web_search` from the assembled request.** A `system-prompt/assemble` listener could drop the declaration and its section, but the tool would stay executable, so a model that repeats an earlier call would still run it, and PTC programs would still bind it. Prompt assembly also no longer carries tool declarations.
|
||||
|
||||
**Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case.
|
||||
|
||||
**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find.
|
||||
|
||||
**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint.
|
||||
|
||||
**Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt.
|
||||
|
||||
**Probe the key or endpoint before offering the tool.** A network request per model request adds latency and cost; the user controls configuration, not its validity at that moment.
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package.
|
||||
|
||||
Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`.
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
# Agent Note: 仅在选中的提供方就绪时提供 web_search 与 web_fetch
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-27-web-search-offered-by-provider-readiness.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。
|
||||
|
||||
## Decision
|
||||
|
||||
web 能力 seam 的每个角色各负责逐步骤检查的一部分:
|
||||
|
||||
- **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。
|
||||
- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。
|
||||
- **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。
|
||||
|
||||
路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。
|
||||
|
||||
只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。
|
||||
|
||||
请求头记录组装后的工具,因此 agent loop(智能体循环)通过工具注册表更新宣告每次变化。路由切换、存储密钥或删除密钥都会改变下一个请求;DeepSeek 路由以延迟加载的新增方式载入重新提供的工具,不支持工具更新的路由则接收完整工具列表。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。
|
||||
|
||||
**扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。
|
||||
|
||||
**从会话最近的 `request/context` 事件读取路由,而不是传入路由。** 该事件记录的是上一个请求,因此新会话的首个请求,以及模型切换后的首个请求,会按错误的路由判断,或根本没有路由可用。
|
||||
|
||||
**在 `dsh-tool-web` 中注册或注销 `web_search`。** 注册由同一 agent preset 的所有会话共享,而凭据取决于每个请求的路由。注销还会使点名 `web_search` 的 `toolOrder` 条目和 subagent `toolFilter` 限制失败。
|
||||
|
||||
**从组装好的请求中移除 `web_search`。** `system-prompt/assemble` 监听器可以删掉该声明及其段落,但工具仍可执行,因此模型重复先前的调用时仍会运行它,PTC 程序也仍能绑定它。提示词组装也已不再携带工具声明。
|
||||
|
||||
**只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。
|
||||
|
||||
**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。
|
||||
|
||||
**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。
|
||||
|
||||
**保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。
|
||||
|
||||
**提供工具前探测密钥或端点。** 每个模型请求都发起一次网络请求会增加延迟与成本;用户控制的是配置本身,而不是它在那一刻是否有效。
|
||||
|
||||
## Consequences
|
||||
|
||||
`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。
|
||||
|
||||
单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。
|
||||
@@ -0,0 +1,19 @@
|
||||
# Bilingual-pair consistency record for 2026-10-21-replaceable-icon-sprite.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite:
|
||||
en: 2b527e550e071451
|
||||
zh: 7de256912270cae0
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/problem:
|
||||
en: d55557fe41ab87c8
|
||||
zh: eea3f7d0c82561bf
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/decision:
|
||||
en: 6ddd80eae56693dd
|
||||
zh: 4d654a76eb64c50a
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/alternatives-considered:
|
||||
en: 2e25ece88bccdb22
|
||||
zh: bb24a28bef7ea7ce
|
||||
/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences:
|
||||
en: 0ad6db37bbb76ddb
|
||||
zh: 0ca09d55a2167970
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: Replaceable product icons through an SVG sprite
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-10-21-replaceable-icon-sprite.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Long-term skin customization needs themes to replace icons, not only colors. Product icons were React components that drew inline SVG paths, so a theme registered through `ctx.theme` could recolor them through `currentColor` but could not change their artwork. About 150 Client files import these components.
|
||||
|
||||
## Decision
|
||||
|
||||
Each product glyph weight is one sprite symbol, `dsw-icon-<glyph>-<weight>`. ui-primitives keeps the default SVG documents as `ICON_SYMBOLS`; an icon component keeps its export name, `size`, and `className`, and renders an `<svg>` box with one `<use>` of its symbol. ui-theme keeps one hidden sprite in the document whose `<symbol>` children come from the active theme's `ThemeDefinition.icons` replacements or the defaults. Artwork reached through `<use>` inherits `currentColor` and CSS custom properties from the use site, so a replacement can be multi-color, can paint with `--dsw-*` color tokens, and follows light and dark palettes without separate values.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Icons as CSS image tokens painted through `mask-image`.** Rejected after design review: a mask keeps only the image's alpha, so every icon stays one color, and a `url()` image cannot read CSS variables, so multi-color artwork could not follow color tokens. It would have reused the token override layer without a new theme field.
|
||||
- **A runtime icon registry service with React components as values.** Rejected: components cannot read services or React context, so every icon import site would need a new prop or slot.
|
||||
- **A slot per icon.** Rejected for the same caller cost; slots carry composition, not artwork.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Icon DOM contains a `<use>` reference instead of paths; tests identify glyphs through `data-icon` and inspect artwork through `ICON_SYMBOLS`.
|
||||
- Without ui-theme, the sprite is absent and icons render nothing.
|
||||
- Every symbol shares the document id space, so `register` requires element ids inside a replacement to start with `<symbol id>-`; `<use>` in another document, such as an iframe, cannot reach the sprite.
|
||||
- Replacement markup is trusted like the registering plugin's code; `register` rejects `<style>` and `<script>` only because they would apply to the whole document.
|
||||
- Gradients and other paint servers resolve their stops where the sprite sits, so they follow palette tokens but not use-site color overrides.
|
||||
- Default and token-colored replacement symbols render pixel-identically through `<use>` and inline in Chromium, WebKit, and Firefox.
|
||||
- File-type, plugin, site, and brand artwork still render inline and are not replaceable yet.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Agent Note: 通过 SVG sprite 实现可替换的产品图标
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-10-21-replaceable-icon-sprite.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
长期的皮肤定制需要主题能替换图标,而不只是颜色。产品图标原先是绘制内联 SVG 路径的 React 组件,因此通过 `ctx.theme` 注册的主题可以借助 `currentColor` 给它们换色,却无法更换图形。约 150 个 Client 文件导入了这些组件。
|
||||
|
||||
## 决策
|
||||
|
||||
每个产品图形的每种线重对应一个 sprite symbol `dsw-icon-<glyph>-<weight>`。ui-primitives 以 `ICON_SYMBOLS` 保存默认 SVG 文档;图标组件保留导出名、`size` 与 `className`,渲染一个 `<svg>` 框,其中包含一个引用自身 symbol 的 `<use>`。ui-theme 在文档中保留一个隐藏的 sprite,其 `<symbol>` 子元素来自活动主题的 `ThemeDefinition.icons` 替换项或默认值。通过 `<use>` 引用的图形会从使用处继承 `currentColor` 与 CSS 自定义属性,因此替换图形可以是多色的,可以用 `--dsw-*` 颜色 token 绘制,并且无需单独的值就能跟随浅色与深色调色板。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
- **把图标作为通过 `mask-image` 绘制的 CSS 图像 token。** 经设计评审后否决:遮罩只保留图像的 alpha,因此每个图标都只能是单色;`url()` 图像无法读取 CSS 变量,因此多色图形无法跟随颜色 token。该方案本可复用 token 覆盖层,而无需新增主题字段。
|
||||
- **以 React 组件为值的运行时图标注册服务。** 否决:组件无法读取服务或 React context,因此每个图标导入处都需要新的 prop 或 slot。
|
||||
- **每个图标一个 slot。** 因同样的调用方成本而否决;slot 承载的是组合,而不是图形。
|
||||
|
||||
## 后果
|
||||
|
||||
- 图标 DOM 包含 `<use>` 引用而不是路径;测试通过 `data-icon` 识别图形,通过 `ICON_SYMBOLS` 检查图形。
|
||||
- 没有 ui-theme 时,sprite 不存在,图标不渲染任何内容。
|
||||
- 所有 symbol 共享文档的 id 空间,因此 `register` 要求替换图形内的元素 id 以 `<symbol id>-` 开头;另一个文档(例如 iframe)中的 `<use>` 无法引用该 sprite。
|
||||
- 替换标记与注册它的插件代码同等受信任;`register` 仅拒绝 `<style>` 与 `<script>`,因为它们会作用于整个文档。
|
||||
- 渐变及其他绘制服务器在 sprite 所在位置解析其色标,因此它们跟随调色板 token,但不跟随使用处的颜色覆盖。
|
||||
- 默认 symbol 与使用 token 着色的替换 symbol 在 Chromium、WebKit 与 Firefox 中通过 `<use>` 渲染与内联渲染的结果逐像素一致。
|
||||
- 文件类型、插件、站点与品牌图形仍以内联方式渲染,暂不可替换。
|
||||
+18
-18
@@ -2,21 +2,21 @@
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-10-20-weighted-approval-status-gate-with-check-run-label.md
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run:
|
||||
en: a1f0f9fd657c7d35
|
||||
zh: e8fa02fb5bde6075
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/problem:
|
||||
en: 8e854872edfc0729
|
||||
zh: 6f3ed495fcb690a4
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/decision:
|
||||
en: 03de94946d7532d7
|
||||
zh: cf2f585f715d3295
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/verification:
|
||||
en: 8298cff64cce6467
|
||||
zh: a121d509c2302c00
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/alternatives-considered:
|
||||
en: 197255cd82aabef7
|
||||
zh: dc03f4bf77150f22
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-adds-a-decision-check-run/consequences:
|
||||
en: 25e1ac1c8cab089a
|
||||
zh: 84e10cccb1b9eda7
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app:
|
||||
en: 1ea2e345d6bc1058
|
||||
zh: 65f872a0606660b8
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/problem:
|
||||
en: f2dc92b64d32bd68
|
||||
zh: 7f24eaaa49a5776f
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/decision:
|
||||
en: 5f7ebd56d43445cf
|
||||
zh: 869e144309b85c28
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/verification:
|
||||
en: c80da76f52dac191
|
||||
zh: 1f7f8458211e1f5b
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/alternatives-considered:
|
||||
en: e3b37e68b3e76d05
|
||||
zh: bb6c917c3fc6b685
|
||||
/agent-note-weighted-approval-keeps-its-commit-status-gate-and-publishes-the-decision-label-from-an-app/consequences:
|
||||
en: 79a97b43e268dd04
|
||||
zh: b85691eac68c313f
|
||||
+15
-11
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Weighted approval keeps its commit status gate and adds a decision check run
|
||||
# Agent Note: Weighted approval keeps its commit status gate and publishes the decision label from an App
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,32 +6,36 @@ English | [中文](2026-10-20-weighted-approval-status-gate-with-check-run-label
|
||||
|
||||
## Problem
|
||||
|
||||
The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work. Moving the gate to a check run does not work: after that change, the check run completed as `success` on the pull-request head and both the REST API and `gh pr checks` listed it, while the merge box kept reporting the same `weighted approval decision` context as `Expected — Waiting for status to be reported`, and two pull requests needed an administrator bypass. That change was reverted.
|
||||
The `weighted approval` commit status is the record this repository's branch rules require, and below the approval threshold it stays `pending`. A commit status has no action-required state, so readers cannot tell an approval wait from running work.
|
||||
|
||||
A check run carries that state, but GitHub attaches a check run created through the API to the earliest Actions check suite on the head, and a check suite created by a `pull_request_target` run is not associated with the pull request. Measured on 2026-10-20 and 2026-10-21: when the earliest suite belonged to a `pull_request` workflow the merge box listed the label, and when it belonged to the `pull_request_target` publisher it listed none, while the Checks page listed it either way; which suite is earliest is a race among the workflows a push starts. Moving the gate to a required check run published with the job token hit the same race: the merge box reported the context as `Expected — Waiting for status to be reported` while the REST API and `gh pr checks` listed it complete, so two pull requests needed an administrator bypass; that change was reverted.
|
||||
|
||||
## Decision
|
||||
|
||||
The publisher keeps writing the `weighted approval` commit status exactly as before: `pending` while the policy is unmet, `success` when it holds, and `error` when setup or evaluation fails. Branch rules keep requiring that status. That behavior, including keeping an unmet policy `pending`, is what the [archived decision](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md) records; this change adds the label beside it instead of changing the gate.
|
||||
|
||||
The publisher additionally writes a `weighted approval decision` check run for the same evaluation: `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails. The check run's title carries the score or the blocker. Branch rules do not require it, so it never blocks a merge; it names the blocked state beside the status that does.
|
||||
The publisher writes the `weighted approval decision` check run with a token minted from a dedicated approval App (`actions/create-github-app-token` with `vars.DSH_APPROVAL_APP_CLIENT_ID`, `secrets.DSH_APPROVAL_APP_PRIVATE_KEY`, and `vars.DSH_APPROVAL_APP_SLUG`, scoped to `checks: write`). The check suite then belongs to the App and is associated with the pull-request head, so the merge box lists the label without depending on which Actions workflow started first. The conclusion is `action_required` while the policy is unmet, `success` when it holds, and `failure` when setup or evaluation fails; the title carries the score or the blocker. Branch rules do not require the check run, so it never blocks a merge.
|
||||
|
||||
A head keeps one GitHub Actions run with the check-run name: the publisher updates the head's existing run and creates one only when none exists. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. A failed check-run write is logged and never changes the status, so a token without the checks permission, or a check-run API failure, leaves the gate as the status wrote it. The publisher identifies its own runs by the `external_id` marker it sets on creation, which excludes check runs that workflow jobs produce for a same-named job. It revokes a published `success` before dependency setup, because GitHub accepts but does not apply a return to `in_progress`.
|
||||
The status and the check run are written through separate API clients: the status gate uses the job token, and only the check-run calls use the App token. A failed App token step or check-run write is logged and never changes the status. When the token or the slug is unavailable the publisher keeps the job token and updates `github-actions` runs only, so the gate keeps working and the label stays on the Checks page; the job token cannot update an App run, so such a head keeps the App run it already has.
|
||||
|
||||
The publisher selects the check runs created by the publishing token by `app.slug`: the App token matches the App's runs and the job token matches `github-actions` runs, because neither token can update a run the other created. It updates every selected run of that app and creates one only when none exists; job-token runs written by earlier evaluations stay untouched and can coexist with the App run on a head created before the App was configured. The setup phase creates the run as `in_progress` on a head without one and deals with a published success by rewriting its conclusion to `action_required`, because GitHub accepts but does not apply a return to `in_progress`. Runs the publisher creates carry the `external_id` marker it sets on creation.
|
||||
|
||||
## Verification
|
||||
|
||||
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions and the setup phase commands.
|
||||
[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the unchanged status states, the check-run conclusion for a met and an unmet policy, the phase writes, revoking a published success through its conclusion, one run per head, invalid list responses, the status fallback when the label write fails, and the split between the status client and the check-run client. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` and `statuses: write` permissions, the App token step and its single secret reference, the `DECISION_TOKEN` wiring, and the setup phase commands.
|
||||
|
||||
The check-run limit was measured on 2026-10-20 with the reverted attempt on this repository: the `weighted approval decision` check run completed as `success` on the pull-request head, the REST API and `gh pr checks` listed it, and the merge box kept reporting the same context as expected.
|
||||
The check-run association limit was measured on 2026-10-20 with the reverted and the previous attempts on this repository: a required check run published with the job token stayed `Expected` in the merge box, and a non-required check run published with the job token was absent from the merge box's check list while the Checks page listed it.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Require the check run instead of the commit status.** Measured above: the ruleset does not match a check run published through the default-branch workflow's token, so the gate cannot be satisfied.
|
||||
**Publish the label with the job token and accept the Checks page only.** That is the state this change replaces; the merge box never lists the label, so the conversation page shows only the pending status.
|
||||
|
||||
**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and would report an unmet, reversible condition as a malfunction.
|
||||
**Require the App check run instead of the commit status.** The App check run is associated with the pull request, so it could carry the gate. That needs a ruleset change to the App's integration and a migration for open heads; this change keeps the proven status gate and only moves the label onto the App.
|
||||
|
||||
**Publish the commit status as `failure` while the policy is unmet.** The status would complete, but this changes the required record's meaning and reports an unmet, reversible condition as a malfunction.
|
||||
|
||||
**Publish only the check run and leave the status untouched.** The status would keep its last state from an earlier evaluation, so the gate and the label could disagree.
|
||||
|
||||
**Publish the required check run from a dedicated GitHub App.** A check run whose app the ruleset names would let the required entry carry the action-required conclusion itself. The repository has no App with the checks permission; with one, the label could move onto the required record and the status could be retired.
|
||||
|
||||
## Consequences
|
||||
|
||||
A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression from the added label. While reviewers are pending, the merge box reports `weighted approval` as pending (the gate) and the `weighted approval decision` entry as `action_required`, with the score or blocker in its title; `gh pr checks` lists both. A published success is revoked by rewriting the check run's conclusion, and the status follows the unchanged policy states.
|
||||
The merge box now lists `weighted approval` (the gate, pending while reviewers are pending) and `weighted approval decision` (`action_required`, with the score or blocker in its title). If the App token cannot be minted, the label falls back to the job token and stays on the Checks page only; the gate is unaffected. A job that ends between the `pending` write and the fallback step, such as a lost runner or a cancellation that skips the final step, leaves the status `pending`; that is the original symptom of this status, not a regression.
|
||||
+14
-10
@@ -1,4 +1,4 @@
|
||||
# Agent Note: Weighted approval 保留其 commit status 门禁并新增决策 check run
|
||||
# Agent Note: Weighted approval 保留 commit status 门禁,并由 App 发布决策标签
|
||||
|
||||
Status: implemented
|
||||
|
||||
@@ -6,32 +6,36 @@ Status: implemented
|
||||
|
||||
## 问题
|
||||
|
||||
`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。把门禁改成 check run 并不可行:该改动后 check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一上下文报告为 `Expected — Waiting for status to be reported`,两个 PR 需要管理员绕过才能合并。该改动已回退。
|
||||
`weighted approval` commit status 是本仓库分支规则要求的记录,在批准阈值下方时保持 `pending`。commit status 没有 action-required 状态,读者无法区分等待批准与正在运行的工作。
|
||||
|
||||
check run 可以承载这个状态,但 GitHub 会把通过 API 创建的 check run 挂到该 head 上最早的 Actions check suite,而 `pull_request_target` 的 run 创建的 check suite 不与该 PR 关联。2026-10-20 与 2026-10-21 实测:最早 suite 属于 `pull_request` workflow 时合并框会列出该标签,属于 `pull_request_target` 的 publisher 时合并框一条都不列,而 Checks 页两种情况下都能列出;哪个 suite 最早是同一次 push 触发的多个 workflow 之间的竞态。把门禁改成用 job token 发布的必需 check run 也撞上同一个竞态:合并框把该上下文报告为 `Expected — Waiting for status to be reported`,而 REST API 与 `gh pr checks` 都显示已完成,两个 PR 需要管理员绕过,该改动已回退。
|
||||
|
||||
## 决定
|
||||
|
||||
publisher 继续完全按原样写 `weighted approval` commit status:策略未满足时为 `pending`,满足时为 `success`,安装或评估失败时为 `error`。分支规则继续要求该 status。该行为——包括未满足的策略保持 `pending`——正是[已归档决定](../../archived/process/2026-09-09-blocked-weighted-approvals-remain-pending.md)记录的;本次变更在该门禁旁边新增标签,而不改变门禁。
|
||||
|
||||
publisher 为同一次评估额外写一个 `weighted approval decision` check run:策略未满足时为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`。check run 的标题携带分数或 blocker。分支规则不要求它,因此它从不阻止合并;它在会阻止合并的那条 status 旁边命名被阻止的状态。
|
||||
publisher 用从专用批准 App 铸造的 token(`actions/create-github-app-token`,使用 `vars.DSH_APPROVAL_APP_CLIENT_ID`、`secrets.DSH_APPROVAL_APP_PRIVATE_KEY` 与 `vars.DSH_APPROVAL_APP_SLUG`,权限限定为 `checks: write`)写 `weighted approval decision` check run。check suite 因此归 App 所有并与 PR head 关联,合并框会列出该标签,不再取决于哪个 Actions workflow 先启动。策略未满足时结论为 `action_required`,满足时为 `success`,安装或评估失败时为 `failure`;标题携带分数或 blocker。分支规则不要求该 check run,因此它从不阻止合并。
|
||||
|
||||
每个 head 只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。check run 写入失败会被记录,且从不改变 status,因此没有 checks 权限的 token 或 check-run API 失败时,门禁保持 status 写入的结果。publisher 用创建时设置的 `external_id` 标记识别自己的 run,从而排除 workflow job 为同名 job 产生的 check run。它在依赖安装前撤销已发布的 `success`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。
|
||||
status 与 check run 通过两个独立的 API client 写入:status 门禁使用 job token,只有 check-run 调用使用 App token。token 步骤失败或 check-run 写入失败会被记录,且从不改变 status,因此 App 权限缺失时门禁照常工作,标签只出现在 Checks 页。
|
||||
|
||||
publisher 按 `app.slug` 选择由当前发布 token 创建的 run:App token 只匹配 App 的 run,job token 只匹配 `github-actions` 的 run,因为两种 token 都无法更新对方创建的 run。它更新所选 app 的所有同名 run,仅在不存在时创建;App 配置之前由 job token 写入的 run 保持不变,可能与 App run 在同一个 head 上共存。setup 阶段对没有 run 的 head 以 `in_progress` 创建,并把已发布的 success 的 conclusion 改写为 `action_required`,因为 GitHub 接受但不执行改回 `in_progress` 的更新。publisher 创建的 run 带创建时设置的 `external_id` 标记。
|
||||
|
||||
## 验证
|
||||
|
||||
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限以及 setup 阶段命令。
|
||||
[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定未改动的 status 状态、策略满足与未满足两种情况下 check run 的 conclusion、阶段写入、通过 conclusion 撤销已发布的成功、每个 head 只有一个 run、无效的列表响应、标签写入失败时 status 的兜底,以及 status client 与 check-run client 的分离。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 与 `statuses: write` 权限、App token 步骤及其唯一的密钥引用、`DECISION_TOKEN` 的接线,以及 setup 阶段命令。
|
||||
|
||||
check run 的限制于 2026-10-20 在本仓库用被回退的尝试实测:`weighted approval decision` check run 在 PR head 上以 `success` 完成,REST API 与 `gh pr checks` 都能列出它,而合并框始终把同一个 `weighted approval decision` 上下文报告为 expected。
|
||||
check run 的关联限制于 2026-10-20 在本仓库用回退的尝试与上一版尝试实测:用 job token 发布的必需 check run 在合并框里保持 `Expected`;用 job token 发布的非必需 check run 不在合并框的检查列表中,而 Checks 页能列出它。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**把 check run 作为必需检查。** 见上面的实测:ruleset 不匹配默认分支 workflow 的 token 发布的 check run,门禁无法满足。
|
||||
**用 job token 发布标签、只接受 Checks 页可见。** 这正是本次变更要替换的状态;合并框永远不列该标签,会话页只显示 pending 的 status。
|
||||
|
||||
**把 App check run 设为必需检查来替代 commit status。** App check run 已关联 PR,可以承载门禁。但那需要把 ruleset 改成该 App 的 integration 并为开放 head 做迁移;本次保留已验证的 status 门禁,只把标签移到 App。
|
||||
|
||||
**策略未满足时把 commit status 发布为 `failure`。** status 会完成,但改变了必需记录的含义,并把尚未满足且可撤销的条件报告为故障。
|
||||
|
||||
**只发布 check run 而不动 status。** status 会保留上一次评估的状态,门禁与标签可能不一致。
|
||||
|
||||
**由专用 GitHub App 发布必需 check run。** 由 ruleset 指定 app 的 check run 可以让必需条目本身携带 action-required 结论。本仓库没有具备 checks 权限的 App;有了之后可以把标签移到必需记录上并停用 status。
|
||||
|
||||
## 后果
|
||||
|
||||
job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。评审未完成时,合并框把 `weighted approval` 报告为 pending(门禁),并把 `weighted approval decision` 条目报告为 `action_required`,标题里是分数或 blocker;`gh pr checks` 会列出两者。已发布的 success 通过改写 check run 的 conclusion 撤销,status 继续按未改动的策略状态变化。
|
||||
合并框现在同时列出 `weighted approval`(门禁,评审未完成时为 pending)与 `weighted approval decision`(`action_required`,标题里是分数或 blocker)。App token 步骤失败或 check-run 写入失败会被记录,且从不改变 status。缺少 token 或 slug 时 publisher 保留 job token、只更新 `github-actions` 的 run,因此门禁照常工作、标签留在 Checks 页;job token 无法更新 App run,这类 head 会保留已有的 App run。job 在 `pending` 写入与兜底步骤之间结束(例如 runner 丢失,或取消导致最后一步未执行)会让 status 保持 `pending`;这是该 status 既有的表现,不是新增标签带来的回归。
|
||||
@@ -1,19 +1,30 @@
|
||||
---
|
||||
name: dsh-session-projection
|
||||
description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating a fold helper to one.
|
||||
description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating session-derived recovery state to one.
|
||||
---
|
||||
|
||||
# DeepSeek Harness Session Projections
|
||||
# Session Projection Rules
|
||||
|
||||
Follow the [projection API](../../../packages/session/session-projection/README.md) for registration and lifecycle details.
|
||||
## Summary
|
||||
|
||||
1. Decide whether to extend an existing projection or create one; check whether an existing owner already maintains the needed state.
|
||||
2. Follow the README's [service ownership rules](../../../packages/session/session-projection/README.md#register-and-read): keep projection definitions and handles private to the owning service's implementation, and expose domain methods to consumers.
|
||||
3. Require `sessionProjections` when the service needs projected state to function. Optional registration is for contributions the service can operate without.
|
||||
4. Evaluate retained state size as the session grows. Do not accumulate state proportional to session length, including collections of event sequence numbers. Growth is acceptable only when slow enough to remain negligible. When the data would keep growing, consider incremental loading instead of retaining the complete state.
|
||||
5. `apply` is synchronous; historical reads belong in asynchronous consumers. When later reads need event payloads, consider retaining sequence numbers within rule 4's state-size limits and letting consumers resolve them asynchronously.
|
||||
6. When replacing a helper such as `foldSessionTitle` with a projection, remove the superseded helper.
|
||||
7. After migrating to Session persistence, stop using `fold` in names and descriptions of the migrated logic.
|
||||
8. Do not add calls to deprecated synchronous readers (`snapshotEvents`, `eventAt`, `ownEvents`) or wrappers exposing the same access ([decision](../../notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Assume the full event log is not resident in memory and random reads may require asynchronous disk access.
|
||||
9. Consider state size, replay cost, and per-event work; add complexity only for a concrete performance need.
|
||||
10. Asynchronous `register()` is usually called in `[Service.init]`. If event listeners are registered afterward, check initialization ordering: when needed, install listeners before awaiting registration; if a listener needs the returned handle, await its readiness in a callback that supports asynchronous waiting. Otherwise, an event may fire before the listener is installed and be missed. Determine whether this race exists from the actual initialization flow and event trigger paths; do not move listeners earlier or add waits mechanically.
|
||||
Keep recoverable session facts in Projection and expose them through their owning services. Read the [API and lifecycle](../../../packages/session/session-projection/README.md) for the supported preparation, registration, read, and cleanup contracts.
|
||||
|
||||
1. Put all persisted or restorable session-derived state in Projection, including host-only state. Reuse an existing owner when suitable. Keep external resources and process-scoped ownership outside persisted state. Persist facts, not registration handles, promises, or runtime resources.
|
||||
|
||||
2. Do not expose projection definitions or handles as production read APIs. Keep the ready handle private; consumers use the owning service's domain methods. A handle is a process-local reader, recreated during activation. Require `sessionProjections` when the service depends on it; optional registration is only for optional contributions. Do not hide a missing required dependency with another recovery path.
|
||||
|
||||
3. State recovery through `init` and `apply` is synchronous and deterministic. Do not perform I/O or trigger Cordis hooks, models, or tools. Runtime hooks own those effects. Replay must not repeat them. Return the same state reference for unrelated events. Keep views synchronous and preserve their references when visible values do not change.
|
||||
|
||||
4. Persist JSON state that its schema accepts. Follow the README's state-version and checkpoint-watermark rules. Restoring a checkpoint and replaying its suffix must match full replay. Change the version when state fields or recovery semantics change.
|
||||
|
||||
5. Prefer fixed-size or sublinear state. Growth is acceptable only when slow enough to remain negligible. Necessary linear state must have a current use and explicit memory, replay, and per-event costs. Do not retain history-sized indexes, including event sequence collections, by habit. Do not discard required data or change behavior to reduce size. Start with a complete implementation; optimize for a concrete need.
|
||||
|
||||
6. Caches must be discardable and rebuildable without changing behavior. A sequence reference alone is insufficient when a synchronous consumer needs the referenced payload. Store the required current value within rule 5, or prepare the payload through an explicit asynchronous path. Do not hide full-log reads in a cache miss. Do not add `snapshotEvents`, `eventAt`, `ownEvents`, or equivalent wrappers: history need not stay in memory. An async wrapper does not make synchronous history reads asynchronous. Existing host replay may remain for named supported callers, with an owner and removal condition; this does not permit [Client cache-miss full-log reads](../../../docs/defensive-patterns/conversation-and-session.md#cold-reads-are-the-last-resort). Remove superseded recovery helpers; cache reconstruction must not duplicate recovery rules.
|
||||
|
||||
7. Await required registration before exposing synchronous readers or installing callbacks that use its handle, usually in `[Service.init]`. Let registration rejection fail that activation. Events can arrive during the await; install listeners earlier only when needed to avoid missed events. Await only in callbacks that support it. Do not move listeners or add waits mechanically. Keep fiber cleanup ownership, await explicit disposal, and do not create effects after the owner is disposed.
|
||||
|
||||
8. Keep registration readiness and Session recovery separate. The asynchronous Session-loading or preparation owner completes historical recovery before it publishes the Session to synchronous readers. Use the registry's supported lifecycle; do not add hydration or hidden I/O to domain getters. Recovery prepares state; getters read it. Preserve the registry's error scope for each unit and Session.
|
||||
|
||||
9. Identify all official consumers before switching a read path, including plugins, CLI/GUI, and both SDKs. Preserve synchronous callers until they migrate explicitly; a Promise cannot implement a synchronous API. A new required service or activation step needs consumer updates and an upgrade guide. Keep compatibility tied to actual supported callers, not test-only compositions.
|
||||
|
||||
10. Verify values, notifications, error scope and timing, and recovery through affected real entry points. Keep existing behavior assertions. Do not move consumer-only errors into eager Projection processing. For lifecycle changes, test delayed registration, rejection, replacement, and disposal during an await. Use actual Session/store lifecycles and explicit barriers instead of partial Session mocks or microtask counts. Check error logs as well as released state. Recovery tests cover usable, missing, and incompatible checkpoints plus suffix replay, without runtime effects.
|
||||
@@ -16,7 +16,7 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
|
||||
|
||||
## Approval scoring
|
||||
|
||||
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. The publisher also writes a `weighted approval decision` check run that reports the same decision as a conclusion — `action_required` while the policy is unmet, `success` when it holds, and `failure` on setup or evaluation failures — with the score or blocker in its title. Branch rules do not require that check run, so it never blocks a merge; it names the blocked state beside the status that gates the merge. A failed check-run write is logged and never changes the status.
|
||||
The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. The publisher also writes a `weighted approval decision` check run that reports the same decision as a conclusion — `action_required` while the policy is unmet, `success` when it holds, and `failure` on setup or evaluation failures — with the score or blocker in its title. Branch rules do not require that check run, so it never blocks a merge; it names the blocked state beside the status that gates the merge. The check run is published with a token minted from a dedicated approval App (`DSH_APPROVAL_APP_CLIENT_ID`, `DSH_APPROVAL_APP_PRIVATE_KEY`, `DSH_APPROVAL_APP_SLUG`, scope `checks: write`): GitHub attaches a check run created with the job token to the earliest Actions check suite on the head, and when that suite belongs to this `pull_request_target` workflow the merge box omits it, so the App identity makes the association stable. The App publishes only runs whose `app.slug` is the App; job-token runs from earlier evaluations stay untouched and can coexist on a head. When the token or slug is missing, the publisher falls back to the job token and updates job-token runs only, which keeps the label on the Checks page. The App token step runs with `continue-on-error`, so a missing App configuration fails only that step and leaves the job-token fallback; a failed check-run write is logged by the publisher and never changes the status.
|
||||
|
||||
Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
|
||||
|
||||
@@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production
|
||||
|
||||
## Security
|
||||
|
||||
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
|
||||
All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code. Its only secret is the dedicated approval App private key, used to mint a token scoped to `checks: write` for the decision check run; the status gate keeps using the job token. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision.
|
||||
|
||||
Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
|
||||
|
||||
|
||||
@@ -308,10 +308,10 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
|
||||
|
||||
/**
|
||||
* Evaluate and publish the required commit status, publishing an error status when evaluation fails.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
|
||||
* @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, checkApi?: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, checkAppSlug?: string, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs; checkApi publishes the decision check run, defaulting to api, and checkAppSlug selects the runs that client can update.
|
||||
* @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
|
||||
*/
|
||||
export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
export async function runApprovalCheck({ event, policySource, api, checkApi = api, checkAppSlug = 'github-actions', runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
|
||||
let result
|
||||
@@ -334,7 +334,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
}
|
||||
} catch (error) {
|
||||
await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
|
||||
await tryPublishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl, write)
|
||||
await tryPublishCheck(checkApi, pull, 'failure', 'Approval evaluation failed.', runUrl, write, checkAppSlug)
|
||||
throw error
|
||||
}
|
||||
write(result.authorCredit
|
||||
@@ -346,7 +346,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
|
||||
writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
|
||||
await publishStatus(api, pull, result.state, result.description, runUrl)
|
||||
await tryPublishCheck(api, pull, result.state === 'success' ? 'success' : 'action_required', result.description, runUrl, write)
|
||||
await tryPublishCheck(checkApi, pull, result.state === 'success' ? 'success' : 'action_required', result.description, runUrl, write, checkAppSlug)
|
||||
write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
|
||||
try {
|
||||
await requestDelegatedReviews(result, api, write)
|
||||
@@ -358,15 +358,15 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
|
||||
|
||||
/**
|
||||
* Revoke a previous success before dependency installation, or report its failure.
|
||||
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, runUrl: string, phase: string, write?: (line: string) => void}} options Publication inputs.
|
||||
* @param {{event: unknown, api: (path: string, options: object) => Promise<unknown>, checkApi?: (path: string, options: object) => Promise<unknown>, checkAppSlug?: string, runUrl: string, phase: string, write?: (line: string) => void}} options Publication inputs; checkApi publishes the decision check run and checkAppSlug selects the runs it can update.
|
||||
* @returns {Promise<void>} Completion of the status write and the corresponding check-run write.
|
||||
*/
|
||||
export async function publishApprovalPhase({ event, api, runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
export async function publishApprovalPhase({ event, api, checkApi = api, checkAppSlug = 'github-actions', runUrl, phase, write = line => process.stdout.write(`${line}\n`) }) {
|
||||
const pull = pullRequestFromEvent(event)
|
||||
if (phase === 'pending') {
|
||||
await publishStatus(api, pull, 'pending', 'Preparing approval evaluation.', runUrl)
|
||||
try {
|
||||
await revokeApproval(api, pull, runUrl)
|
||||
await revokeApproval(checkApi, pull, runUrl, checkAppSlug)
|
||||
} catch (error) {
|
||||
write(`Decision check run not revoked; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
|
||||
}
|
||||
@@ -374,7 +374,7 @@ export async function publishApprovalPhase({ event, api, runUrl, phase, write =
|
||||
}
|
||||
if (phase === 'error') {
|
||||
await publishStatus(api, pull, 'error', 'Approval setup or evaluation failed.', runUrl)
|
||||
return tryPublishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl, write)
|
||||
return tryPublishCheck(checkApi, pull, 'failure', 'Approval setup or evaluation failed.', runUrl, write, checkAppSlug)
|
||||
}
|
||||
throw new Error('invalid approval setup phase')
|
||||
}
|
||||
@@ -486,20 +486,20 @@ async function reviewerPermission(api, repository, login) {
|
||||
// The required gate is the commit status; this check run reports the same decision as a conclusion
|
||||
// label and is not required. A failed label write is reported and never changes the gate, including
|
||||
// for a replayed workflow whose token has no checks permission.
|
||||
async function tryPublishCheck(api, pull, conclusion, title, runUrl, write) {
|
||||
async function tryPublishCheck(checkApi, pull, conclusion, title, runUrl, write, appSlug) {
|
||||
try {
|
||||
await publishCheck(api, pull, conclusion, title, runUrl)
|
||||
await publishCheck(checkApi, pull, conclusion, title, runUrl, appSlug)
|
||||
} catch (error) {
|
||||
write(`Decision check run not published; the commit status is unchanged: ${JSON.stringify(error instanceof Error ? error.message : String(error))}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function publishCheck(api, pull, conclusion, title, runUrl) {
|
||||
async function publishCheck(checkApi, pull, conclusion, title, runUrl, appSlug) {
|
||||
requireHttpsRunUrl(runUrl)
|
||||
const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } }
|
||||
const runs = await decisionRuns(api, pull)
|
||||
const runs = await decisionRuns(checkApi, pull, appSlug)
|
||||
if (runs.length === 0) {
|
||||
await api(`/repos/${pull.repository}/check-runs`, {
|
||||
await checkApi(`/repos/${pull.repository}/check-runs`, {
|
||||
method: 'POST',
|
||||
body: { name: CHECK_NAME, head_sha: pull.headSha, external_id: CHECK_RUN_MARKER, ...body },
|
||||
})
|
||||
@@ -507,21 +507,21 @@ async function publishCheck(api, pull, conclusion, title, runUrl) {
|
||||
}
|
||||
for (const run of runs) {
|
||||
if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id')
|
||||
await api(`/repos/${pull.repository}/check-runs/${run.id}`, { method: 'PATCH', body })
|
||||
await checkApi(`/repos/${pull.repository}/check-runs/${run.id}`, { method: 'PATCH', body })
|
||||
}
|
||||
}
|
||||
|
||||
// A head without a decision starts in progress; a published success becomes action_required, so an
|
||||
// interrupted evaluation cannot leave a success that its current inputs no longer support.
|
||||
async function revokeApproval(api, pull, runUrl) {
|
||||
async function revokeApproval(checkApi, pull, runUrl, appSlug) {
|
||||
requireHttpsRunUrl(runUrl)
|
||||
const title = 'Preparing approval evaluation.'
|
||||
const output = { title, summary: `${title} [Workflow run](${runUrl})` }
|
||||
const runs = await decisionRuns(api, pull)
|
||||
const runs = await decisionRuns(checkApi, pull, appSlug)
|
||||
if (runs.length === 0) {
|
||||
await api(`/repos/${pull.repository}/check-runs`, {
|
||||
await checkApi(`/repos/${pull.repository}/check-runs`, {
|
||||
method: 'POST',
|
||||
body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output },
|
||||
body: { name: CHECK_NAME, head_sha: pull.headSha, external_id: CHECK_RUN_MARKER, status: 'in_progress', details_url: runUrl, output },
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -529,7 +529,7 @@ async function revokeApproval(api, pull, runUrl) {
|
||||
if (!Number.isSafeInteger(run.id)) throw new Error('check run has no valid id')
|
||||
// The label is not a gate; only a published success has to stop claiming the policy holds.
|
||||
if (run.conclusion !== 'success') continue
|
||||
await api(`/repos/${pull.repository}/check-runs/${run.id}`, {
|
||||
await checkApi(`/repos/${pull.repository}/check-runs/${run.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output },
|
||||
})
|
||||
@@ -537,13 +537,14 @@ async function revokeApproval(api, pull, runUrl) {
|
||||
}
|
||||
|
||||
// A completed check run never returns to in_progress: GitHub answers 200 and leaves it completed.
|
||||
async function decisionRuns(api, pull) {
|
||||
const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=100`)
|
||||
async function decisionRuns(checkApi, pull, appSlug) {
|
||||
const listed = await checkApi(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=100`)
|
||||
if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) {
|
||||
throw new Error('check-run list response is invalid or incomplete')
|
||||
}
|
||||
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions'
|
||||
&& (run.external_id === CHECK_RUN_MARKER || run.external_id === ''))
|
||||
// Only runs created by the publishing token can be updated: the job token cannot patch an App run
|
||||
// and the App token cannot patch an Actions run, so the slug selects the runs this token owns.
|
||||
return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === appSlug)
|
||||
}
|
||||
|
||||
function requireHttpsRunUrl(runUrl) {
|
||||
@@ -640,6 +641,17 @@ async function main() {
|
||||
token: process.env.GITHUB_TOKEN ?? '',
|
||||
apiUrl: process.env.GITHUB_API_URL,
|
||||
})
|
||||
// The decision check run is published by the dedicated approval App so GitHub associates its check
|
||||
// suite with the pull request instead of whichever Actions suite started first. Both the token and
|
||||
// the App slug must be configured; otherwise the publisher keeps the job token, whose runs only
|
||||
// show on the Checks page.
|
||||
const appToken = process.env.DECISION_TOKEN
|
||||
const appSlug = process.env.DECISION_APP_SLUG
|
||||
const useApp = Boolean(appToken && appSlug)
|
||||
const checkApi = useApp
|
||||
? createGitHubApi({ token: appToken, apiUrl: process.env.GITHUB_API_URL })
|
||||
: api
|
||||
const checkAppSlug = useApp ? appSlug : 'github-actions'
|
||||
if (isRecord(event) && isRecord(event.issue)) {
|
||||
event = await approvalEventFromComment({ event, api })
|
||||
} else if (isRecord(event) && isRecord(event.workflow_run)) {
|
||||
@@ -653,7 +665,7 @@ async function main() {
|
||||
}
|
||||
const phase = process.argv[2]
|
||||
if (phase) {
|
||||
await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
|
||||
await publishApprovalPhase({ event, api, checkApi, checkAppSlug, runUrl: process.env.GITHUB_RUN_URL ?? '', phase })
|
||||
if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n')
|
||||
return
|
||||
}
|
||||
@@ -661,6 +673,8 @@ async function main() {
|
||||
event,
|
||||
policySource,
|
||||
api,
|
||||
checkApi,
|
||||
checkAppSlug,
|
||||
runUrl: process.env.GITHUB_RUN_URL ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -497,6 +497,92 @@ test('publishes setup phases without evaluating or installing dependencies', asy
|
||||
await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
|
||||
})
|
||||
|
||||
test('updates the App-owned run instead of creating another', async () => {
|
||||
const calls = []
|
||||
const appRuns = [{ id: 42, app: { slug: 'dsh-weighted-approval' }, conclusion: 'success', external_id: 'weighted-approval-decision' }]
|
||||
await runPublisher({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
checkAppSlug: 'dsh-weighted-approval',
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
getMergedCount: async () => 0,
|
||||
api: async (path, options = {}) => {
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected status API path ${path}`)
|
||||
},
|
||||
checkApi: async (path, options = {}) => {
|
||||
calls.push({ path, method: options.method, body: options.body })
|
||||
if (isCheckRunsList(path)) return { total_count: appRuns.length, check_runs: appRuns }
|
||||
if (path.includes('/check-runs')) return {}
|
||||
throw new Error(`unexpected check API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.deepEqual(calls.map(call => call.path), [
|
||||
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/42',
|
||||
])
|
||||
assert.equal(calls[1].method, 'PATCH')
|
||||
assert.equal(calls[1].body.name, undefined)
|
||||
})
|
||||
|
||||
test('ignores job-token runs when the App token publishes', async () => {
|
||||
const calls = []
|
||||
const jobRuns = [{ id: 7, app: { slug: 'github-actions' }, conclusion: 'success', external_id: 'weighted-approval-decision' }]
|
||||
await runPublisher({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
checkAppSlug: 'dsh-weighted-approval',
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
getMergedCount: async () => 0,
|
||||
api: async (path) => {
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected status API path ${path}`)
|
||||
},
|
||||
checkApi: async (path, options = {}) => {
|
||||
calls.push({ path, method: options.method, body: options.body })
|
||||
if (isCheckRunsList(path)) return { total_count: jobRuns.length, check_runs: jobRuns }
|
||||
if (path.includes('/check-runs')) return {}
|
||||
throw new Error(`unexpected check API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.equal(calls.at(-1).method, 'POST')
|
||||
assert.equal(calls.at(-1).body.name, 'weighted approval decision')
|
||||
assert.equal(calls.at(-1).body.external_id, 'weighted-approval-decision')
|
||||
})
|
||||
|
||||
test('publishes the check run through the App token when one is provided', async () => {
|
||||
const statusCalls = []
|
||||
const checkCalls = []
|
||||
await runPublisher({
|
||||
event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {},
|
||||
getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
|
||||
getMergedCount: async () => 0,
|
||||
api: async (path, options = {}) => {
|
||||
statusCalls.push({ path, body: options.body })
|
||||
if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
|
||||
if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
|
||||
if (path.includes('/comments?')) return []
|
||||
if (path.includes('/statuses/')) return {}
|
||||
throw new Error(`unexpected status API path ${path}`)
|
||||
},
|
||||
checkApi: async (path, options = {}) => {
|
||||
checkCalls.push({ path, body: options.body })
|
||||
if (isCheckRunsList(path)) return emptyCheckRuns
|
||||
if (path.includes('/check-runs')) return {}
|
||||
throw new Error(`unexpected check API path ${path}`)
|
||||
},
|
||||
})
|
||||
assert.equal(statusCalls.at(-1).body.context, 'weighted approval')
|
||||
assert.equal(checkCalls.at(-1).body.name, 'weighted approval decision')
|
||||
assert.equal(checkCalls.at(-1).body.conclusion, 'action_required')
|
||||
assert.ok(statusCalls.every(call => !call.path.includes('/check-runs')))
|
||||
assert.ok(checkCalls.every(call => !call.path.includes('/statuses/')))
|
||||
})
|
||||
|
||||
test('keeps the status gate when the label write fails', async () => {
|
||||
const writes = []
|
||||
const states = []
|
||||
@@ -561,6 +647,7 @@ test('revokes a published success check run and updates one run per head', async
|
||||
`/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
|
||||
`/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`,
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/7',
|
||||
'/repos/deepseek-harness/deepseek-harness/check-runs/10',
|
||||
])
|
||||
for (const call of calls.slice(2)) {
|
||||
assert.equal(call.method, 'PATCH')
|
||||
|
||||
@@ -37,10 +37,26 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
# The decision check run is published by a dedicated approval App so GitHub associates its
|
||||
# check suite with the pull request and no other workflow owns that identity; the job token
|
||||
# alone keeps the run out of the merge box. The App holds only Checks write. A missing App
|
||||
# permission leaves the token empty and the publisher falls back to the job token.
|
||||
- name: Create decision check token
|
||||
id: decision-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
with:
|
||||
client-id: ${{ vars.DSH_APPROVAL_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.DSH_APPROVAL_APP_PRIVATE_KEY }}
|
||||
owner: deepseek-harness
|
||||
repositories: deepseek-harness
|
||||
permission-checks: write
|
||||
- name: Revoke previous approval status
|
||||
id: revoke
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DECISION_TOKEN: ${{ steps.decision-token.outputs.token }}
|
||||
DECISION_APP_SLUG: ${{ vars.DSH_APPROVAL_APP_SLUG }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs pending
|
||||
# SECURITY: dependency setup runs in the status-writing job.
|
||||
@@ -57,11 +73,15 @@ jobs:
|
||||
if: steps.revoke.outputs.active == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DECISION_TOKEN: ${{ steps.decision-token.outputs.token }}
|
||||
DECISION_APP_SLUG: ${{ vars.DSH_APPROVAL_APP_SLUG }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs
|
||||
- name: Publish approval setup failure
|
||||
if: failure() && steps.revoke.outputs.active == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
DECISION_TOKEN: ${{ steps.decision-token.outputs.token }}
|
||||
DECISION_APP_SLUG: ${{ vars.DSH_APPROVAL_APP_SLUG }}
|
||||
GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/review-ownership/check-approval.mjs error
|
||||
@@ -160,6 +160,7 @@ pnpm applies local patches to the following packages at install time, so shipped
|
||||
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
|
||||
|
||||
## Chrome DevTools frontend
|
||||
|
||||
@@ -41,3 +41,9 @@
|
||||
config:
|
||||
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
|
||||
workspaceRoot: !!js process.cwd()
|
||||
|
||||
# A search key reference no runner sets: the turn runs as a deployment without
|
||||
# web search credentials, whatever the launching environment exports.
|
||||
- id: web-search-deepseek
|
||||
config:
|
||||
apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY
|
||||
@@ -60,7 +60,9 @@ describe('headless-agent keyless smoke', () => {
|
||||
})
|
||||
expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP')
|
||||
expect(persistedHeader).toMatchObject({ type: 'session' })
|
||||
expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch', 'web_search']))
|
||||
expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch']))
|
||||
// Search has no key in this composition, so the model request does not offer it.
|
||||
expect(persistedToolNames).not.toContain('web_search')
|
||||
expect(persistedToolNames).not.toContain('str_replace_editor')
|
||||
}, PRODUCTION_PROFILE_TEST_TIMEOUT_MS)
|
||||
})
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
fixtureContext,
|
||||
normalizeSessionSnapshot,
|
||||
normalizeSessionSnapshots,
|
||||
REPLAY_CREDENTIAL_ENV,
|
||||
type NormalizeContext,
|
||||
} from '@deepseek-ai/dsh-session-snapshot'
|
||||
import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
|
||||
@@ -104,6 +105,7 @@ describe('parent-only override inheritance snapshot', () => {
|
||||
binArgs: [configPath, task],
|
||||
tsconfigPath,
|
||||
env: {
|
||||
...REPLAY_CREDENTIAL_ENV,
|
||||
// The primary fixture path must exist for llm-replay's config guard;
|
||||
// the override sidecar fully replaces the derived parent script.
|
||||
DSH_SNAPSHOT_FILE: replayOverride,
|
||||
|
||||
@@ -16,6 +16,7 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
|
||||
'@yao-pkg/[email protected]': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
|
||||
'[email protected]': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
|
||||
'[email protected]': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
|
||||
'[email protected]': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
|
||||
'[email protected]': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,9 @@ it('classifies the current workspace and shares only runtime patch bytes', () =>
|
||||
const before = readFileSync(join(repository, 'pnpm-lock.yaml'))
|
||||
const plan = prepareRuntimePatches(project, repository)
|
||||
expect(plan.patches.map(entry => entry.spec)).toEqual(['@earendil-works/[email protected]', '[email protected]'])
|
||||
expect(plan.workspaceOnly).toHaveLength(7)
|
||||
expect(plan.workspaceOnly).toHaveLength(8)
|
||||
expect(plan.workspaceOnly).toContain('@botiverse/oar')
|
||||
expect(plan.workspaceOnly).toContain('lexical')
|
||||
const settings = yaml.load(readFileSync(join(project, 'pnpm-workspace.yaml'), 'utf8')) as { overrides: Record<string, string> }
|
||||
expect(settings.overrides['@earendil-works/pi-ai']).toBeUndefined()
|
||||
expect(settings.overrides['@earendil-works/pi-ai@^1.3.0']).toBe('1.3.0')
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Browser selection and focus after presses in the composer's unused space. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium, type Page } from 'playwright'
|
||||
import { chromium, webkit, type Page } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode,
|
||||
@@ -39,6 +39,55 @@ function describeSelection(state: Awaited<ReturnType<typeof selectionState>>): s
|
||||
return `${state.focused ? 'focused' : 'unfocused'}; selection ${JSON.stringify(state.text)} (${state.anchor} → ${state.focus})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the visible composer still when keyboard panning changes viewport coordinates.
|
||||
* @param page - Page with the focused composer.
|
||||
* @returns The observed page and composer scroll behavior.
|
||||
*/
|
||||
async function exerciseKeyboardViewport(page: Page): Promise<string> {
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]')
|
||||
const restore = await page.evaluateHandle(() => {
|
||||
const viewport = window.visualViewport
|
||||
if (viewport === null) throw new Error('Expected a visual viewport')
|
||||
const style = document.documentElement.style
|
||||
const height = style.getPropertyValue('height')
|
||||
const priority = style.getPropertyPriority('height')
|
||||
const scroll = window.scrollY
|
||||
const offset = Object.getOwnPropertyDescriptor(viewport, 'offsetTop')
|
||||
style.setProperty('height', `${window.innerHeight * 2}px`)
|
||||
const input = document.querySelector<HTMLElement>('[data-composer-input]')!
|
||||
window.scrollTo(0, input.getBoundingClientRect().top + scroll - window.innerHeight / 4)
|
||||
// Safari can report the keyboard's page displacement as offsetTop even
|
||||
// though DOM rectangles already account for that page scroll.
|
||||
Object.defineProperty(viewport, 'offsetTop', { configurable: true, get: () => window.scrollY })
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
return () => {
|
||||
if (offset === undefined) Reflect.deleteProperty(viewport, 'offsetTop')
|
||||
else Object.defineProperty(viewport, 'offsetTop', offset)
|
||||
if (height === '') style.removeProperty('height')
|
||||
else style.setProperty('height', height, priority)
|
||||
window.scrollTo(0, scroll)
|
||||
window.dispatchEvent(new Event('scroll'))
|
||||
}
|
||||
})
|
||||
try {
|
||||
const before = await input.evaluate(el => ({ top: el.getBoundingClientRect().top, scroll: window.scrollY }))
|
||||
expect(before.scroll).toBeGreaterThan(0)
|
||||
expect(before.top).toBeGreaterThanOrEqual(0)
|
||||
await page.keyboard.insertText('z')
|
||||
const after = await input.evaluate(el => new Promise<{ top: number; scroll: number }>((resolve) => {
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
resolve({ top: el.getBoundingClientRect().top, scroll: window.scrollY })
|
||||
}))
|
||||
}))
|
||||
expect(after).toEqual(before)
|
||||
return '- typing with a keyboard-panned viewport preserves page scroll and composer position'
|
||||
} finally {
|
||||
await restore.evaluate((dispose) => { dispose() })
|
||||
await restore.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exercise the card's top, inter-row gap, toolbar gap and bottom padding.
|
||||
* @param page - Page containing an editable composer.
|
||||
@@ -113,6 +162,15 @@ async function exerciseComposer(page: Page): Promise<string[]> {
|
||||
await page.keyboard.insertText('!')
|
||||
await expect.poll(() => input.textContent()).toBe('alpha BET!A gamma')
|
||||
observations.push(`- typing replaces the restored selection and resumes at the restored caret: ${await input.textContent()}`)
|
||||
const retained = await input.elementHandle()
|
||||
const beforeResize = await selectionState(page)
|
||||
for (const width of [390, 768, 1680]) {
|
||||
await page.setViewportSize({ width, height: 1000 })
|
||||
expect(await retained!.evaluate(element => element.isConnected)).toBe(true)
|
||||
expect(await selectionState(page)).toEqual(beforeResize)
|
||||
}
|
||||
observations.push('- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret')
|
||||
observations.push(await exerciseKeyboardViewport(page))
|
||||
return observations
|
||||
}
|
||||
|
||||
@@ -145,7 +203,87 @@ it('restores the previous caret or selection in fresh and existing sessions', as
|
||||
'# Composer whitespace focus', '', '## Fresh session', '', ...hero,
|
||||
'', '## Existing session', '', ...docked,
|
||||
].join('\n'), webSnapshotMode())
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md'])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md', 'submission.expected.md'])
|
||||
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
}, 180_000)
|
||||
|
||||
it('keeps mobile submission keyboard navigation in the composer while retaining desktop focus', async () => {
|
||||
const observations: string[] = []
|
||||
for (const { engine, mobile, submission } of [
|
||||
{ engine: chromium, mobile: false, submission: 'Send' },
|
||||
{ engine: chromium, mobile: true, submission: 'Send' },
|
||||
{ engine: chromium, mobile: true, submission: 'Enter' },
|
||||
{ engine: webkit, mobile: true, submission: 'Send' },
|
||||
{ engine: webkit, mobile: true, submission: 'Enter' },
|
||||
]) {
|
||||
const scaffold = await launchWebScaffold({
|
||||
replayFixture: fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url)),
|
||||
compareReplaySession: false,
|
||||
})
|
||||
try {
|
||||
const browser = await engine.launch()
|
||||
try {
|
||||
const page = await browser.newPage({
|
||||
viewport: { width: mobile ? 390 : 1280, height: 844 },
|
||||
locale: 'en-US', timezoneId: 'Asia/Shanghai',
|
||||
...mobile ? {
|
||||
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1',
|
||||
isMobile: true, hasTouch: true,
|
||||
} : {},
|
||||
})
|
||||
const tripwire = watchConsole(page)
|
||||
const platform = `${engine.name()} ${mobile ? 'mobile' : 'desktop'} ${submission}`
|
||||
let finished = false
|
||||
const off = scaffold.ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'turn/end') finished = true
|
||||
})
|
||||
try {
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd, `submission-${platform}`)
|
||||
const input = page.locator('[data-composer-input][contenteditable="true"]')
|
||||
await input.fill('Reply with a one-sentence description of event sourcing, then stop.')
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
if (submission === 'Enter') await page.keyboard.press('Enter')
|
||||
else {
|
||||
const send = page.getByRole('button', { name: 'Send message', exact: true })
|
||||
if (mobile) await send.tap()
|
||||
else await send.click()
|
||||
}
|
||||
await expect.poll(() => input.textContent()).toBe('')
|
||||
await expect.poll(() => input.evaluate(element => document.activeElement === element)).toBe(!mobile)
|
||||
await expect.poll(() => finished, { timeout: 60_000 }).toBe(true)
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(!mobile)
|
||||
if (mobile) {
|
||||
const card = page.locator('[data-composer-card]')
|
||||
expect(await card.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
// Option+Tab includes buttons in WebKit's keyboard navigation mode.
|
||||
const next = engine === webkit ? 'Alt+Tab' : 'Tab'
|
||||
await page.keyboard.press(next)
|
||||
expect(await input.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await page.keyboard.press(next)
|
||||
const attachment = page.getByRole('button', { name: 'Add files or run commands', exact: true })
|
||||
expect(await attachment.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
observations.push(`- ${platform}: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button`)
|
||||
} else {
|
||||
observations.push(`- ${platform}: clears the draft; editor stays focused through response completion`)
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
if (webSnapshotMode() !== 'record') expect(tripwire.warnings).toEqual([])
|
||||
} catch (error) {
|
||||
await saveFailureShot(page, `web-e2e-composer-submission-${platform}`)
|
||||
throw error
|
||||
} finally {
|
||||
off()
|
||||
await page.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
}
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'submission.expected.md'), observations.join('\n'), webSnapshotMode())
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['focus.expected.md', 'submission.expected.md'])
|
||||
}, 180_000)
|
||||
@@ -199,6 +199,99 @@ describe('web e2e: the composer model switch is the default for later sessions',
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', automatic: false },
|
||||
{ device: 'Android Chrome', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', automatic: false },
|
||||
{ device: 'iPhone Chrome', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 CriOS/139.0.0.0 Mobile/15E148 Safari/604.1', automatic: false },
|
||||
{ device: 'narrow desktop', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', automatic: true },
|
||||
])('respects $device composer sizing and search focus when opening /model', async ({ userAgent, automatic }) => {
|
||||
const context = await browser.newContext({
|
||||
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: !automatic,
|
||||
})
|
||||
try {
|
||||
const devicePage = await context.newPage()
|
||||
const consoleWatch = watchConsole(devicePage)
|
||||
await devicePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
const composer = devicePage.locator('[data-composer-input][contenteditable="true"]').first()
|
||||
await composer.waitFor()
|
||||
expect(await composer.evaluate(element => getComputedStyle(element).fontSize)).toBe(automatic ? '14px' : '16px')
|
||||
await composer.fill('/model')
|
||||
await devicePage.getByRole('option', { name: /^模型/ }).click()
|
||||
const search = devicePage.getByRole('textbox', { name: '筛选选项', exact: true })
|
||||
await search.waitFor()
|
||||
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
|
||||
expect(await search.evaluate(input => document.activeElement === input)).toBe(automatic)
|
||||
await search.click()
|
||||
expect(await search.evaluate(input => document.activeElement === input)).toBe(true)
|
||||
await search.fill('Origin')
|
||||
await devicePage.getByRole('option', { name: /Origin Large/ }).waitFor()
|
||||
expect(await devicePage.getByRole('option', { name: /Acme/ }).count()).toBe(0)
|
||||
await search.press('Escape')
|
||||
await composer.fill('')
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ device: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone' },
|
||||
{ device: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel' },
|
||||
])('retains model menu keyboard focus on $device', async ({ userAgent, platform }) => {
|
||||
const context = await browser.newContext({
|
||||
userAgent, viewport: { width: 390, height: 844 }, locale: ZH_BROWSER_LOCALE, hasTouch: true,
|
||||
})
|
||||
try {
|
||||
await context.addInitScript(({ platform }) => {
|
||||
Object.defineProperty(navigator, 'platform', { configurable: true, value: platform })
|
||||
Object.defineProperty(navigator, 'maxTouchPoints', { configurable: true, value: 5 })
|
||||
}, { platform })
|
||||
const devicePage = await context.newPage()
|
||||
const consoleWatch = watchConsole(devicePage)
|
||||
await devicePage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await devicePage.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
const trigger = devicePage.getByRole('button', { name: /^选择模型/ })
|
||||
const observations: string[] = []
|
||||
const observeFocus = async (action: string): Promise<void> => {
|
||||
const focused = await devicePage.evaluate(() => {
|
||||
const element = document.activeElement!
|
||||
const role = element.getAttribute('role') ?? element.tagName.toLowerCase()
|
||||
const label = element.getAttribute('aria-label') ?? element.textContent?.replace(/\s+/g, ' ').trim()
|
||||
return `${role}: ${label}`
|
||||
})
|
||||
observations.push(`${action}: ${focused}`)
|
||||
}
|
||||
await trigger.click()
|
||||
await expect.poll(() => trigger.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Open')
|
||||
await devicePage.keyboard.press('ArrowDown')
|
||||
await devicePage.keyboard.press('Enter')
|
||||
const model = devicePage.getByRole('menuitemradio', { name: 'Origin Large', exact: true })
|
||||
await expect.poll(() => model.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Enter model pane')
|
||||
await devicePage.keyboard.press('ArrowDown')
|
||||
const search = devicePage.getByRole('searchbox', { name: '搜索模型…' })
|
||||
await expect.poll(() => search.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Navigate models')
|
||||
await devicePage.keyboard.press('Escape')
|
||||
const modelCell = devicePage.getByRole('menuitem', { name: /^模型/ })
|
||||
await expect.poll(() => modelCell.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Return to root')
|
||||
await devicePage.keyboard.press('Escape')
|
||||
await expect.poll(() => trigger.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
await observeFocus('Close')
|
||||
expect(await trigger.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
await compareOrRefreshGolden(
|
||||
fileURLToPath(new URL('./expected/default-model/mobile-keyboard.expected.md', import.meta.url)),
|
||||
observations.join('\n'), webSnapshotMode(),
|
||||
)
|
||||
} finally {
|
||||
await context.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('shares provider order, fuzzy result order and sticky header material between both model pickers', async () => {
|
||||
const readGroups = (surface: Locator, role: 'option' | 'menuitemradio') => surface.locator('[data-menu-group]')
|
||||
.evaluateAll((groups, rowRole) => groups.map(group => ({
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
- bottom padding: focused; selection "beta" (10 → 6)
|
||||
- return from outside: focused; selection "beta" (10 → 6)
|
||||
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
|
||||
- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret
|
||||
- typing with a keyboard-panned viewport preserves page scroll and composer position
|
||||
|
||||
## Existing session
|
||||
|
||||
@@ -17,3 +19,5 @@
|
||||
- bottom padding: focused; selection "beta" (10 → 6)
|
||||
- return from outside: focused; selection "beta" (10 → 6)
|
||||
- typing replaces the restored selection and resumes at the restored caret: alpha BET!A gamma
|
||||
- resizing through 390px, 768px and 1680px preserves the editor, focus, draft and caret
|
||||
- typing with a keyboard-panned viewport preserves page scroll and composer position
|
||||
@@ -0,0 +1,5 @@
|
||||
- chromium desktop Send: clears the draft; editor stays focused through response completion
|
||||
- chromium mobile Send: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
- chromium mobile Enter: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
- webkit mobile Send: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
- webkit mobile Enter: clears the draft; focus remains on the composer card through response completion, then advances to the editor and attachment button
|
||||
@@ -0,0 +1,5 @@
|
||||
Open: button: 选择模型,当前 Origin Large
|
||||
Enter model pane: menuitemradio: Origin Large
|
||||
Navigate models: searchbox: 搜索模型…
|
||||
Return to root: menuitem: 模型Origin Large
|
||||
Close: button: 选择模型,当前 Origin Large
|
||||
@@ -0,0 +1,9 @@
|
||||
# Plugin header natural scrolling
|
||||
|
||||
- Title / 1280px: resting title 28, actions 28, tabs none; pinned actions 16, tabs none, bar 64; stable range, reversible coordinates, same controls.
|
||||
- Title / 600px: resting title 28, actions 28, tabs none; pinned actions 16, tabs none, bar 64; stable range, reversible coordinates, same controls.
|
||||
- Title / 390px: resting title 28, actions 96, tabs none; pinned actions 16, tabs none, bar 64; stable range, reversible coordinates, same controls.
|
||||
- Tabs / 1280px: resting title 28, actions 28, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
|
||||
- Tabs / 600px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
|
||||
- Tabs / 470px: resting title 28, actions 97, tabs 96; pinned actions 17, tabs 16, bar 66; stable range, reversible coordinates, same controls.
|
||||
- Tabs / 390px: resting title 28, actions 146, tabs 96; pinned actions 66, tabs 16, bar 114; stable range, reversible coordinates, same controls.
|
||||
@@ -4,16 +4,16 @@ en 1680px: actions beside the heading, single-line; contained; information follo
|
||||
en 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 390px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 430px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
en 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 1680px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 768px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 520px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 440px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 390px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 430px: actions below the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 390px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 430px: actions beside or below (according to intrinsic fit) the heading, single-line; contained; information follows subtitle; focus retained
|
||||
zh 1024px: actions beside the heading, single-line; contained; information follows subtitle; focus retained
|
||||
|
||||
A forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.
|
||||
A forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes.
|
||||
Add menu dismisses on Escape and returns focus in both locales.
|
||||
@@ -0,0 +1,21 @@
|
||||
/** Test theme that replaces one product icon with two-tone artwork painted from theme tokens. */
|
||||
window.__ModuleLoader__.load({
|
||||
id: '@fixture/icon-skin',
|
||||
factory() {
|
||||
const icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">'
|
||||
+ '<circle data-skin-disc="" cx="8" cy="8" r="6" fill="var(--dsw-alias-state-success-primary)"/>'
|
||||
+ '<path d="M5 8h6" stroke="currentColor"/></svg>'
|
||||
return {
|
||||
inject: ['theme'],
|
||||
apply(ctx) {
|
||||
ctx.effect(() => ctx.theme.register({
|
||||
id: 'fixture-icon-skin',
|
||||
colorScheme: 'light',
|
||||
tokens: {},
|
||||
icons: { 'dsw-icon-search-outline-regular': icon },
|
||||
}))
|
||||
ctx.theme.setTheme('fixture-icon-skin')
|
||||
},
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,3 @@
|
||||
- insert:
|
||||
- id: fixture-icon-skin
|
||||
name: '@fixture/icon-skin'
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Host companion for the icon skin fixture. */
|
||||
export function apply() {}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@fixture/icon-skin",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"exports": { ".": "./index.js", "./client": "./client.js" },
|
||||
"dsh": {
|
||||
"bundle": { "patch": "./cordis.patch.yml" },
|
||||
"client": { "platform": "web" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Real profile and Loader: a theme plugin replaces a product icon through the sprite, and removing it restores the default. */
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { chromium } from 'playwright'
|
||||
import { expect, it, onTestFailed, onTestFinished } from 'vitest'
|
||||
import { launchWebScaffold } from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./fixtures/plugins/fixture-icon-skin', import.meta.url))
|
||||
const ICON = 'svg[data-icon="dsw-icon-search-outline-regular"]'
|
||||
|
||||
it('renders product icons from the sprite and swaps in a theme plugin replacement', async () => {
|
||||
const scaffold = await launchWebScaffold({ extraInstallAnchors: [join(FIXTURE, 'package.json')] })
|
||||
onTestFinished(() => scaffold.close())
|
||||
const browser = await chromium.launch()
|
||||
onTestFinished(() => browser.close())
|
||||
const page = await newEnglishPage(browser)
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-icon-skin'))
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
const icon = page.locator(ICON).first()
|
||||
await icon.waitFor({ timeout: 20_000 })
|
||||
// getBBox measures the geometry <use> actually resolved: zero without a symbol.
|
||||
const geometry = () => icon.evaluate((svg: SVGSVGElement) => {
|
||||
const box = svg.querySelector('use')!.getBBox()
|
||||
return [box.x, box.y, box.width, box.height].map(value => Math.round(value * 10) / 10)
|
||||
})
|
||||
const defaults = await geometry()
|
||||
expect(defaults[2]).toBeGreaterThan(0)
|
||||
expect(await page.locator('svg[data-icon]').evaluateAll(icons => icons.filter(svg => svg.getBoundingClientRect().width > 0)
|
||||
.every(svg => (svg.querySelector('use') as SVGUseElement).getBBox().width > 0))).toBe(true)
|
||||
|
||||
const entryId = await scaffold.ctx.loader.create({ name: '@fixture/icon-skin' })
|
||||
// The replacement disc spans viewBox units 2..14 of 16 on both axes.
|
||||
const width = await icon.evaluate((svg: SVGSVGElement) => svg.getBoundingClientRect().width)
|
||||
await expect.poll(geometry).toEqual([2, 2, 12, 12].map(units => Math.round((width * units) / 16 * 10) / 10))
|
||||
const disc = page.locator('svg[data-dsh-icon-sprite] symbol[id="dsw-icon-search-outline-regular"] [data-skin-disc]')
|
||||
expect(await disc.evaluate(element => getComputedStyle(element).fill)).not.toBe('none')
|
||||
|
||||
scaffold.ctx.loader.remove(entryId)
|
||||
await expect.poll(geometry).toEqual(defaults)
|
||||
await expect.poll(() => disc.count()).toBe(0)
|
||||
})
|
||||
@@ -107,8 +107,10 @@ describe('web e2e: plugin manager', () => {
|
||||
const headerScaffold = await launchWebScaffold()
|
||||
try {
|
||||
// Pin the extremes; at intermediate widths, the rendered font and locale
|
||||
// determine whether the two intrinsic items fit beside each other.
|
||||
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside', 390: 'below', 430: 'below' }
|
||||
// determine whether the two intrinsic items fit beside each other. Narrow
|
||||
// widths shrink the actions to icons before they wrap; the collapsed-bar case
|
||||
// pins those widths.
|
||||
const PINNED_PLACEMENT: Record<number, 'beside' | 'below'> = { 1680: 'beside', 1024: 'beside' }
|
||||
for (const language of ['en', 'zh'] as const) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1680, height: 1000 }, locale: language === 'en' ? 'en-US' : ZH_BROWSER_LOCALE,
|
||||
@@ -121,7 +123,7 @@ describe('web e2e: plugin manager', () => {
|
||||
const title = language === 'en' ? 'Plugins' : '插件'
|
||||
await probe.getByRole('button', { name: title, exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const header = panel.locator(':scope > header')
|
||||
const header = panel.locator('[data-plugin-bar] > header')
|
||||
await header.getByRole('heading', { name: title, exact: true }).waitFor()
|
||||
await probe.evaluate(() => document.fonts.ready)
|
||||
const add = header.getByRole('button', { name: language === 'en' ? 'Add plugin' : '添加插件', exact: true })
|
||||
@@ -129,13 +131,23 @@ describe('web e2e: plugin manager', () => {
|
||||
await add.focus()
|
||||
const measure = () => header.evaluate((element) => {
|
||||
const heading = element.firstElementChild!
|
||||
const actions = element.lastElementChild!
|
||||
const headingRect = heading.getBoundingClientRect()
|
||||
const actions = element.querySelector('[data-plugin-toolbar]')!
|
||||
const titleRect = heading.querySelector('h1')!.getBoundingClientRect()
|
||||
const introRect = heading.querySelector('h1 + div')!.getBoundingClientRect()
|
||||
const headingRect = {
|
||||
right: Math.max(titleRect.right, introRect.right),
|
||||
bottom: Math.max(titleRect.bottom, introRect.bottom),
|
||||
width: Math.max(titleRect.right, introRect.right) - Math.min(titleRect.left, introRect.left),
|
||||
}
|
||||
const actionRect = actions.getBoundingClientRect()
|
||||
const headerRect = element.getBoundingClientRect()
|
||||
const intrinsicHeading = heading.cloneNode(true) as HTMLElement
|
||||
intrinsicHeading.querySelectorAll('[data-plugin-header-part]').forEach((part) => {
|
||||
part.removeAttribute('data-plugin-header-part')
|
||||
part.removeAttribute('style')
|
||||
})
|
||||
Object.assign(intrinsicHeading.style, {
|
||||
position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none',
|
||||
display: 'block', position: 'absolute', width: 'max-content', maxWidth: 'none', visibility: 'hidden', pointerEvents: 'none',
|
||||
})
|
||||
element.append(intrinsicHeading)
|
||||
let intrinsicHeadingWidth: number
|
||||
@@ -194,10 +206,12 @@ describe('web e2e: plugin manager', () => {
|
||||
if (pinned !== undefined) expect(placement).toBe(pinned)
|
||||
facts.push(`${language} ${width}px: actions ${pinned === undefined ? 'beside or below (according to intrinsic fit)' : placement} the heading, single-line; contained; information follows subtitle; focus retained`)
|
||||
}
|
||||
await probe.setViewportSize({ width: 390, height: 1000 })
|
||||
// Force less room than even the icon actions need beside the heading.
|
||||
await probe.setViewportSize({ width: 240, height: 1000 })
|
||||
const previousWrap = await header.evaluate((element) => {
|
||||
const style = element.style
|
||||
const previous = { value: style.getPropertyValue('flex-wrap'), priority: style.getPropertyPriority('flex-wrap') }
|
||||
element.parentElement!.removeAttribute('data-natural')
|
||||
style.setProperty('flex-wrap', 'nowrap')
|
||||
return previous
|
||||
})
|
||||
@@ -210,8 +224,10 @@ describe('web e2e: plugin manager', () => {
|
||||
await header.evaluate((element, previous) => {
|
||||
if (previous.value === '') element.style.removeProperty('flex-wrap')
|
||||
else element.style.setProperty('flex-wrap', previous.value, previous.priority)
|
||||
element.parentElement!.setAttribute('data-natural', '')
|
||||
}, previousWrap)
|
||||
}
|
||||
await probe.setViewportSize({ width: 390, height: 1000 })
|
||||
await expect.poll(async () => validLayout(await measure())).toBe(true)
|
||||
const menuTrigger = header.getByRole('button', { name: language === 'en' ? 'Choose how to add a plugin' : '选择添加插件方式' })
|
||||
await menuTrigger.click()
|
||||
@@ -226,7 +242,7 @@ describe('web e2e: plugin manager', () => {
|
||||
}
|
||||
await compareOrRefreshGolden(
|
||||
join(SNAPSHOT_DIR, 'header-responsive.expected.md'),
|
||||
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 390px fails the same measured layout check in both locales; restoring wrap passes.\nAdd menu dismisses on Escape and returns focus in both locales.`,
|
||||
`# Plugin header responsive layout\n\n${facts.join('\n')}\n\nA forced no-wrap layout at 240px fails the same measured layout check in both locales; restoring wrap and 390px passes.\nAdd menu dismisses on Escape and returns focus in both locales.`,
|
||||
MODE,
|
||||
)
|
||||
} finally {
|
||||
@@ -234,6 +250,166 @@ describe('web e2e: plugin manager', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses icon actions before shrinking tabs and wraps only when both still cannot fit', async () => {
|
||||
const compactScaffold = await launchWebScaffold({
|
||||
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
|
||||
})
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 480 }, locale: 'en-US', reducedMotion: 'reduce' })
|
||||
try {
|
||||
const probe = await context.newPage()
|
||||
await probe.goto(compactScaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await probe.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
const panel = probe.locator('[data-plugin-panel]')
|
||||
const bar = panel.locator('[data-plugin-bar]')
|
||||
const body = panel.locator('[data-plugin-body]')
|
||||
const refresh = bar.getByRole('button', { name: 'Refresh', exact: true })
|
||||
const add = bar.getByRole('button', { name: 'Add plugin', exact: true })
|
||||
await panel.locator('[data-plugin-package]').first().waitFor()
|
||||
await bar.getByRole('tablist').waitFor()
|
||||
await add.focus()
|
||||
for (const [width, icons, narrowTabs, wraps] of [
|
||||
[1280, false, false, false], [600, true, false, false],
|
||||
[470, true, true, false], [390, true, true, true], [300, true, true, true],
|
||||
] as const) {
|
||||
await probe.setViewportSize({ width, height: 480 })
|
||||
await body.evaluate((element) => { element.scrollTop = 180 })
|
||||
await probe.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
|
||||
await expect.poll(() => bar.evaluate((element) => {
|
||||
const tablist = element.querySelector('[role="tablist"]')!
|
||||
const toolbar = element.querySelector('[data-plugin-toolbar]')!
|
||||
const action = toolbar.querySelector('button')!
|
||||
const tabRect = tablist.getBoundingClientRect(), actions = toolbar.getBoundingClientRect()
|
||||
const actionRect = action.getBoundingClientRect()
|
||||
const wrapped = actions.top >= tabRect.bottom
|
||||
return {
|
||||
icons: getComputedStyle(action.lastElementChild!).display === 'none',
|
||||
narrowTabs: tabRect.width < 240,
|
||||
wraps: wrapped,
|
||||
centered: wrapped || Math.abs(tabRect.top + tabRect.height / 2 - actionRect.top - actionRect.height / 2) < 1,
|
||||
}
|
||||
}), { message: `compact toolbar at ${width}px` }).toEqual({ icons, narrowTabs, wraps, centered: true })
|
||||
expect(await add.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
// The collapsed bar hides the title visually but keeps the page's heading for assistive technology.
|
||||
expect(await bar.getByRole('heading', { name: 'Plugins', level: 1, exact: true }).count()).toBe(1)
|
||||
}
|
||||
// A short list keeps its range even when the available travel cannot fully compact the tabs.
|
||||
await probe.setViewportSize({ width: 1280, height: 480 })
|
||||
await body.evaluate((element) => { element.scrollTop = 0 })
|
||||
await expect.poll(() => bar.evaluate(element => element.hasAttribute('data-compact'))).toBe(false)
|
||||
const range = await body.evaluate(element => element.scrollHeight - element.clientHeight)
|
||||
await probe.setViewportSize({ width: 1280, height: 480 + range - 30 })
|
||||
await expect.poll(() => body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(30)
|
||||
await probe.mouse.move(800, 400)
|
||||
await probe.mouse.wheel(0, 200)
|
||||
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBe(30)
|
||||
expect(await body.evaluate(element => element.scrollHeight - element.clientHeight)).toBe(30)
|
||||
expect(await bar.evaluate(element => element.hasAttribute('data-compact'))).toBe(false)
|
||||
await probe.setViewportSize({ width: 390, height: 480 })
|
||||
// Locator hover scrolls its target into view, and scrolling this list moves the header controls away from
|
||||
// the pointer; once the resized header stops moving, move the pointer onto each control instead.
|
||||
const pointAt = async (target: Locator): Promise<void> => {
|
||||
await expect.poll(() => target.evaluate(element => new Promise<boolean>((resolve) => {
|
||||
const before = element.getBoundingClientRect()
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
const after = element.getBoundingClientRect()
|
||||
resolve(before.x === after.x && before.y === after.y)
|
||||
}))
|
||||
}))).toBe(true)
|
||||
const bounds = await target.boundingBox()
|
||||
if (bounds === null) throw new Error('Header control is not visible')
|
||||
await probe.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
}
|
||||
await pointAt(refresh)
|
||||
await probe.getByRole('tooltip', { name: 'Refresh', exact: true }).waitFor()
|
||||
await pointAt(add)
|
||||
await probe.getByRole('tooltip', { name: 'Add plugin', exact: true }).waitFor()
|
||||
await bar.getByRole('button', { name: 'Choose how to add a plugin', exact: true }).click()
|
||||
await probe.getByRole('menu').waitFor()
|
||||
await probe.keyboard.press('Escape')
|
||||
await probe.getByRole('menu').waitFor({ state: 'hidden' })
|
||||
await add.click()
|
||||
await probe.getByRole('dialog').waitFor()
|
||||
} finally {
|
||||
await context.close()
|
||||
await compactScaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the plugin page title and actions pinned while the cards scroll', async () => {
|
||||
await page.setViewportSize({ width: 1000, height: 480 })
|
||||
const panel = await openPluginsPanel()
|
||||
const body = panel.locator('[data-plugin-body]')
|
||||
try {
|
||||
await panel.locator('[data-plugin-package]').first().waitFor()
|
||||
const headerTop = () => panel.locator('[data-plugin-bar] > header').evaluate(element => element.getBoundingClientRect().top)
|
||||
const bar = panel.locator('[data-plugin-bar]')
|
||||
const barHeight = () => bar.evaluate(element => element.getBoundingClientRect().height)
|
||||
const resting = await headerTop()
|
||||
const restingHeight = await barHeight()
|
||||
const refreshButton = bar.getByRole('button', { name: '刷新', exact: true })
|
||||
const addButton = bar.getByRole('button', { name: '添加插件', exact: true })
|
||||
const restingActions = await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()])
|
||||
expect(resting).toBe(await panel.evaluate(element => element.getBoundingClientRect().top))
|
||||
const restingBarBottom = await bar.evaluate(element => element.getBoundingClientRect().bottom)
|
||||
expect(await body.locator('[data-plugin-group]').first().evaluate(element => element.getBoundingClientRect().top))
|
||||
.toBeCloseTo(restingBarBottom + 32, 1)
|
||||
await body.evaluate((element) => { element.scrollTop = element.scrollHeight })
|
||||
const bottom = await body.evaluate(element => element.scrollTop)
|
||||
expect(bottom).toBeGreaterThan(100)
|
||||
expect(await headerTop()).toBe(resting)
|
||||
// The painted bar compacts while its document-flow height remains unchanged.
|
||||
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true)
|
||||
expect(await barHeight()).toBe(restingHeight)
|
||||
expect(await bar.evaluate(element => getComputedStyle(element, '::after').borderBottomWidth)).not.toBe('0px')
|
||||
expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height))).toBe(64)
|
||||
expect(await body.evaluate(element => element.getBoundingClientRect().top)).toBe(resting)
|
||||
expect(await Promise.all([refreshButton.boundingBox(), addButton.boundingBox()]))
|
||||
.toEqual(restingActions.map((bounds) => {
|
||||
if (bounds === null) throw new Error('Plugin toolbar action is not visible')
|
||||
return { ...bounds, y: bounds.y - 12 }
|
||||
}))
|
||||
// Reverse keyboard navigation scrolls each focused control into view below the bar, never under it.
|
||||
await panel.locator('[data-plugin-package] [role="switch"]').last().focus()
|
||||
const barBottom = await bar.evaluate(element => element.getBoundingClientRect().top + Number.parseFloat(getComputedStyle(element, '::before').height))
|
||||
const hidden: string[] = []
|
||||
for (let step = 0; step < 30; step++) {
|
||||
await page.keyboard.press('Shift+Tab')
|
||||
const focus = await page.evaluate(() => {
|
||||
const element = document.activeElement
|
||||
const box = element?.getBoundingClientRect()
|
||||
return {
|
||||
inBody: element?.closest('[data-plugin-body]') !== null && element?.closest('[data-plugin-bar]') === null,
|
||||
top: box?.top ?? 0,
|
||||
name: element?.getAttribute('aria-label') ?? element?.textContent ?? '',
|
||||
}
|
||||
})
|
||||
if (!focus.inBody) break
|
||||
if (focus.top < barBottom) hidden.push(`${focus.name} at ${String(focus.top)}`)
|
||||
}
|
||||
expect(hidden).toEqual([])
|
||||
expect(await body.evaluate(element => element.scrollTop)).toBeLessThan(bottom)
|
||||
expect(await headerTop()).toBe(resting)
|
||||
await page.setViewportSize({ width: 1680, height: 480 })
|
||||
await body.evaluate((element) => { element.scrollTop = element.scrollHeight })
|
||||
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact') === true)
|
||||
await expect.poll(() => panel.evaluate((element) => {
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const barElement = element.querySelector('[data-plugin-bar]')!
|
||||
const barBounds = barElement.getBoundingClientRect()
|
||||
const bodyBounds = element.querySelector('[data-plugin-body]')!.getBoundingClientRect()
|
||||
const dividerStyle = getComputedStyle(barElement, '::after')
|
||||
return {
|
||||
left: barBounds.left + Number.parseFloat(dividerStyle.left) - bounds.left,
|
||||
right: barBounds.right - Number.parseFloat(dividerStyle.right) - bounds.right,
|
||||
gap: bodyBounds.top - barBounds.top,
|
||||
}
|
||||
})).toEqual({ left: 0, right: 0, gap: 0 })
|
||||
} finally {
|
||||
await body.evaluate((element) => { element.scrollTop = 0 })
|
||||
await page.setViewportSize({ width: 1680, height: 1000 })
|
||||
}
|
||||
})
|
||||
|
||||
it('aligns the first-read skeleton with the loaded plugin cards', async () => {
|
||||
const facts: string[] = []
|
||||
let aria = ''
|
||||
@@ -263,7 +439,7 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(await skeleton.getAttribute('role')).toBe('status')
|
||||
expect(await skeleton.getAttribute('aria-label')).toBe('正在读取插件…')
|
||||
expect(await panel.getAttribute('aria-busy')).toBe('true')
|
||||
const actions = panel.locator(':scope > header > div:last-child button')
|
||||
const actions = panel.locator('[data-plugin-bar] > header > div:last-child button')
|
||||
expect(await actions.count()).toBe(3)
|
||||
for (const action of await actions.all()) expect(await action.isDisabled()).toBe(true)
|
||||
expect(await panel.getByRole('button', { name: '插件说明' }).isEnabled()).toBe(true)
|
||||
@@ -278,7 +454,7 @@ describe('web e2e: plugin manager', () => {
|
||||
return { x, y, width, height }
|
||||
}
|
||||
return {
|
||||
pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector(':scope > header')),
|
||||
pageHeader: rect(element.closest('[data-plugin-panel]')?.querySelector('[data-plugin-bar] > header')),
|
||||
groupHeader: rect(element.firstElementChild),
|
||||
rows: Array.from(element.querySelectorAll(selector)).slice(0, 4).map((row) => {
|
||||
const head = row.firstElementChild
|
||||
@@ -559,7 +735,9 @@ describe('web e2e: plugin manager', () => {
|
||||
await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor()
|
||||
facts.push('Mouse hover after touch: refresh tooltip visible')
|
||||
await probe.mouse.move(0, 843)
|
||||
expect(await help.count()).toBe(0)
|
||||
// Tab moved focus to the icon-only Add plugin button, whose focus tooltip stays; the hovered ones leave.
|
||||
expect(await probe.getByRole('tooltip').filter({ hasText: '刷新' }).count()).toBe(0)
|
||||
expect(await probe.getByRole('tooltip').filter({ hasText: '在这里配置官方插件' }).count()).toBe(0)
|
||||
await input.detach()
|
||||
await probe.clock.resume()
|
||||
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'),
|
||||
@@ -1258,7 +1436,8 @@ describe('web e2e: plugin manager', () => {
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, [
|
||||
'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md',
|
||||
'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md',
|
||||
'ime-enter.expected.md', 'header-responsive.expected.md', 'header-natural-motion.expected.md', 'preview.expected.md',
|
||||
'touch-tooltips.expected.md',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium, type Page } from 'playwright'
|
||||
import { expect, it } from 'vitest'
|
||||
import { compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode } from './scaffold.ts'
|
||||
|
||||
const EXPECTED = fileURLToPath(new URL('./expected/plugin-manager/header-natural-motion.expected.md', import.meta.url))
|
||||
|
||||
/** Measure the accepted resting or compact flex layout without changing the live header. */
|
||||
async function reference(page: Page, compact: boolean) {
|
||||
return page.locator('[data-plugin-bar]').evaluate((bar, compact) => {
|
||||
const clone = bar.cloneNode(true) as HTMLElement
|
||||
clone.removeAttribute('data-natural')
|
||||
clone.toggleAttribute('data-compact', compact)
|
||||
clone.setAttribute('data-measuring', '')
|
||||
clone.inert = true
|
||||
clone.style.width = `${bar.getBoundingClientRect().width}px`
|
||||
clone.querySelectorAll('[data-plugin-header-part]').forEach((element) => { element.removeAttribute('style') })
|
||||
bar.parentElement!.append(clone)
|
||||
try {
|
||||
const origin = clone.querySelector('header')!.getBoundingClientRect()
|
||||
const box = (selector: string) => {
|
||||
const element = clone.querySelector(selector)
|
||||
if (element === null) return null
|
||||
const rect = element.getBoundingClientRect()
|
||||
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }
|
||||
}
|
||||
return {
|
||||
height: clone.getBoundingClientRect().height,
|
||||
title: box('h1')!, intro: box('[data-plugin-header-part="intro"]')!,
|
||||
toolbar: box('[data-plugin-toolbar]')!, tabs: box('[role="tablist"]'),
|
||||
}
|
||||
} finally { clone.remove() }
|
||||
}, compact)
|
||||
}
|
||||
|
||||
/** Wait for scroll delivery before sampling the resulting layout. */
|
||||
async function scrollTo(page: Page, offset: number): Promise<void> {
|
||||
await page.locator('[data-plugin-body]').evaluate(async (body, offset) => {
|
||||
body.scrollTop = offset
|
||||
await new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
|
||||
})
|
||||
}, offset)
|
||||
}
|
||||
|
||||
async function geometry(page: Page) {
|
||||
return page.locator('[data-plugin-bar]').evaluate((bar) => {
|
||||
const origin = bar.querySelector('header')!.getBoundingClientRect()
|
||||
const body = bar.parentElement!
|
||||
const box = (selector: string) => {
|
||||
const element = bar.querySelector(selector)
|
||||
if (element === null) return null
|
||||
const rect = element.getBoundingClientRect()
|
||||
return { x: rect.x - origin.x, y: rect.y - origin.y, width: rect.width, height: rect.height }
|
||||
}
|
||||
return {
|
||||
title: box('h1')!, intro: box('[data-plugin-header-part="intro"]')!,
|
||||
toolbar: box('[data-plugin-toolbar]')!, tabs: box('[role="tablist"]'),
|
||||
barHeight: bar.getBoundingClientRect().height,
|
||||
paintHeight: Number.parseFloat(getComputedStyle(bar, '::before').height),
|
||||
travel: Number.parseFloat((bar as HTMLElement).style.getPropertyValue('--plugin-header-travel')),
|
||||
range: body.scrollHeight - body.clientHeight, offset: body.scrollTop,
|
||||
first: body.querySelector('[data-plugin-group]')!.getBoundingClientRect().top,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('preserves both header layouts and follows the scroll position without changing the scroll range', async () => {
|
||||
const browser = await chromium.launch()
|
||||
const facts: string[] = []
|
||||
try {
|
||||
for (const hasTabs of [false, true]) {
|
||||
const scaffold = await launchWebScaffold(hasTabs ? {
|
||||
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
|
||||
} : {})
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 480 }, locale: 'en-US' })
|
||||
const consoleWatch = watchConsole(page)
|
||||
try {
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package]').first().waitFor()
|
||||
await page.locator('[data-plugin-bar][data-natural]').waitFor()
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
// Without tabs, whether the actions fit beside the title at 470px depends on the platform font,
|
||||
// so that width is pinned only with tabs.
|
||||
for (const width of hasTabs ? [1280, 600, 470, 390] : [1280, 600, 390]) {
|
||||
await page.setViewportSize({ width, height: 480 })
|
||||
await scrollTo(page, 0)
|
||||
const resting = await reference(page, false)
|
||||
const compact = await reference(page, true)
|
||||
const initial = await geometry(page)
|
||||
expect(initial.title).toEqual(resting.title)
|
||||
expect(initial.toolbar).toEqual(resting.toolbar)
|
||||
expect(initial.tabs).toEqual(resting.tabs)
|
||||
if (hasTabs && width <= 600) {
|
||||
const tabs = initial.tabs!
|
||||
if (width === 390) {
|
||||
expect(initial.toolbar.y).toBeGreaterThanOrEqual(tabs.y + tabs.height)
|
||||
} else {
|
||||
expect(initial.toolbar.x).toBeGreaterThanOrEqual(tabs.x + tabs.width)
|
||||
expect(initial.toolbar.y + initial.toolbar.height / 2).toBeCloseTo(tabs.y + tabs.height / 2, 5)
|
||||
}
|
||||
}
|
||||
const refresh = await page.getByRole('button', { name: 'Refresh', exact: true }).elementHandle()
|
||||
if (refresh === null) throw new Error('The refresh control is missing')
|
||||
const samples: Awaited<ReturnType<typeof geometry>>[] = []
|
||||
const offsets = [...new Set([
|
||||
0, 1, 4, 8, 9, ...Array.from({ length: Math.ceil(initial.travel / 4) + 1 }, (_, i) => i * 4),
|
||||
initial.travel, initial.travel + 16,
|
||||
])].sort((a, b) => a - b)
|
||||
for (const offset of offsets) {
|
||||
await scrollTo(page, offset)
|
||||
const current = await geometry(page)
|
||||
expect(current.range).toBe(initial.range)
|
||||
expect(current.barHeight).toBe(resting.height)
|
||||
if (hasTabs && width <= 600) {
|
||||
expect(current.toolbar.y - current.tabs!.y).toBeCloseTo(initial.toolbar.y - initial.tabs!.y, 5)
|
||||
expect(current.toolbar.x).toBe(initial.toolbar.x)
|
||||
expect(current.tabs!.x).toBe(initial.tabs!.x)
|
||||
}
|
||||
expect(current.first).toBeCloseTo(initial.first - current.offset, 5)
|
||||
const previous = samples.at(-1)
|
||||
if (previous !== undefined) {
|
||||
expect(current.toolbar.y).toBeLessThanOrEqual(previous.toolbar.y + 0.02)
|
||||
if (current.tabs !== null && previous.tabs !== null) expect(current.tabs.y).toBeLessThanOrEqual(previous.tabs.y + 0.02)
|
||||
}
|
||||
if (offset === 8) {
|
||||
expect(current.paintHeight).toBeGreaterThan(compact.height)
|
||||
if (hasTabs) expect(current.tabs!.y).toBeCloseTo(resting.tabs!.y - 8, 5)
|
||||
}
|
||||
samples.push(current)
|
||||
}
|
||||
const terminal = await geometry(page)
|
||||
expect(terminal.toolbar).toEqual(compact.toolbar)
|
||||
expect(terminal.tabs).toEqual(compact.tabs)
|
||||
expect(terminal.paintHeight).toBe(compact.height)
|
||||
if (!hasTabs) expect(terminal.title).toEqual(compact.title)
|
||||
expect(await refresh.evaluate(element => element.isConnected)).toBe(true)
|
||||
await refresh.focus()
|
||||
expect(await refresh.evaluate(element => element === document.activeElement)).toBe(true)
|
||||
await scrollTo(page, terminal.offset)
|
||||
expect(await geometry(page)).toEqual(terminal)
|
||||
for (const sample of samples.toReversed()) {
|
||||
await scrollTo(page, sample.offset)
|
||||
expect(await geometry(page)).toEqual(sample)
|
||||
}
|
||||
expect(await page.locator('[data-measuring]').count()).toBe(0)
|
||||
facts.push(`- ${hasTabs ? 'Tabs' : 'Title'} / ${width}px: resting title ${resting.title.y}, actions ${resting.toolbar.y}, tabs ${resting.tabs?.y ?? 'none'}; pinned actions ${compact.toolbar.y}, tabs ${compact.tabs?.y ?? 'none'}, bar ${compact.height}; stable range, reversible coordinates, same controls.`)
|
||||
}
|
||||
expect(consoleWatch.pageErrors).toEqual([])
|
||||
expect(consoleWatch.warnings).toEqual([])
|
||||
} finally { await page.close() }
|
||||
} finally { await scaffold.close() }
|
||||
}
|
||||
} finally { await browser.close() }
|
||||
await compareOrRefreshGolden(EXPECTED, `# Plugin header natural scrolling\n\n${facts.join('\n')}`, webSnapshotMode())
|
||||
})
|
||||
|
||||
it('preserves the full header width when the list reserves a scrollbar gutter', async () => {
|
||||
const scaffold = await launchWebScaffold({
|
||||
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
|
||||
})
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 480 }, locale: 'en-US' })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package]').first().waitFor()
|
||||
await page.locator('[data-plugin-bar][data-natural]').waitFor()
|
||||
await scrollTo(page, 0)
|
||||
const initial = await geometry(page)
|
||||
const compact = await reference(page, true)
|
||||
const headerBounds = await page.locator('[data-plugin-bar] > header').boundingBox()
|
||||
await page.locator('[data-plugin-body]').evaluate((body) => { body.style.scrollbarGutter = 'stable' })
|
||||
await expect.poll(() => page.locator('[data-plugin-body]').evaluate(body => (body as HTMLElement).offsetWidth - body.clientWidth))
|
||||
.toBeGreaterThan(0)
|
||||
await expect.poll(() => page.locator('[data-plugin-bar] > header').boundingBox()).toEqual(headerBounds)
|
||||
const withGutter = await geometry(page)
|
||||
expect(withGutter.toolbar).toEqual(initial.toolbar)
|
||||
expect(withGutter.tabs).toEqual(initial.tabs)
|
||||
await scrollTo(page, 300)
|
||||
expect((await geometry(page)).toolbar).toEqual(compact.toolbar)
|
||||
expect((await geometry(page)).tabs).toEqual(compact.tabs)
|
||||
} finally { await browser.close() }
|
||||
} finally { await scaffold.close() }
|
||||
})
|
||||
|
||||
it('does not manufacture extra scroll range or clamp a nearly fitting list while the header moves', async () => {
|
||||
const scaffold = await launchWebScaffold()
|
||||
try {
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1000, height: 480 }, locale: 'en-US' })
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package]').first().waitFor()
|
||||
await page.locator('[data-plugin-bar][data-natural]').waitFor()
|
||||
await scrollTo(page, 0)
|
||||
const initial = await geometry(page)
|
||||
await page.setViewportSize({ width: 1000, height: 480 + initial.range - 12 })
|
||||
await scrollTo(page, 0)
|
||||
const before = await geometry(page)
|
||||
expect(before.range).toBe(12)
|
||||
await page.mouse.move(500, 300)
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(async () => (await geometry(page)).offset).toBe(12)
|
||||
const bottom = await geometry(page)
|
||||
expect(bottom.range).toBe(12)
|
||||
expect(bottom.barHeight).toBe(before.barHeight)
|
||||
expect(bottom.paintHeight).toBe(before.barHeight - 12)
|
||||
await page.mouse.wheel(0, -200)
|
||||
await expect.poll(async () => (await geometry(page)).offset).toBe(0)
|
||||
expect(await geometry(page)).toEqual(before)
|
||||
} finally { await browser.close() }
|
||||
} finally { await scaffold.close() }
|
||||
})
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
redactSessionSnapshotIds,
|
||||
normalizeSessionSnapshots,
|
||||
parseSessionFixtureName,
|
||||
REPLAY_CREDENTIAL_ENV,
|
||||
scrubModelRequestBulk,
|
||||
scrubSessionSnapshot,
|
||||
sessionFixtureFiles,
|
||||
@@ -499,10 +500,15 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
throw new Error('deepSeekMissingCredential is a keyless replay/refresh option')
|
||||
}
|
||||
const maskDeepSeekCredential = mode !== 'record' && options.deepSeekMissingCredential === true
|
||||
// A keyless run that replays recorded model calls pins the snapshot replay placeholder: shipped
|
||||
// compositions offer web_search only while a DeepSeek search key is configured, and recordings
|
||||
// ran with a real key. Other runs keep the launching environment, as their goldens do.
|
||||
const pinDeepSeekCredential = mode !== 'record' && !maskDeepSeekCredential
|
||||
&& options.replayFixture !== undefined && options.replayProvidersOnly !== true
|
||||
const originalDeepSeekCredential = process.env.DEEPSEEK_API_KEY
|
||||
let credentialEnvironmentRestored = false
|
||||
const restoreCredentialEnvironment = (): void => {
|
||||
if (credentialEnvironmentRestored || !maskDeepSeekCredential) return
|
||||
if (credentialEnvironmentRestored || (!maskDeepSeekCredential && !pinDeepSeekCredential)) return
|
||||
credentialEnvironmentRestored = true
|
||||
if (originalDeepSeekCredential === undefined) {
|
||||
Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
@@ -562,6 +568,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
throw error
|
||||
}
|
||||
if (maskDeepSeekCredential) Reflect.deleteProperty(process.env, 'DEEPSEEK_API_KEY')
|
||||
if (pinDeepSeekCredential) Object.assign(process.env, REPLAY_CREDENTIAL_ENV)
|
||||
|
||||
// The include patch set — the same layer stack the profile boot composes
|
||||
// (bundle patches in dsh.profile.bundles order), applied over the SAME empty root (a
|
||||
|
||||
@@ -198,10 +198,14 @@ describe('web e2e: macOS window drag coverage', () => {
|
||||
})
|
||||
|
||||
/**
|
||||
* Open a darwin-marked page in the shipped composition.
|
||||
* Open a desktop-marked page in the shipped composition.
|
||||
* @param platform - Desktop CSS and preload platform to emulate in Chromium.
|
||||
* @param host - Managed profile serving this page.
|
||||
* @returns the page and its console tripwire.
|
||||
*/
|
||||
async function darwinPage(): Promise<{ page: Page; tripwire: ReturnType<typeof watchConsole> }> {
|
||||
async function desktopPage(
|
||||
platform: 'darwin' | 'win32' = 'darwin', host: WebScaffold = scaffold,
|
||||
): Promise<{ page: Page; tripwire: ReturnType<typeof watchConsole> }> {
|
||||
const page = await newEnglishPage(browser)
|
||||
await page.addInitScript(({ value, snapshot }) => {
|
||||
// The Desktop platform marker requires the preload's keyboard and preference capabilities.
|
||||
@@ -211,18 +215,112 @@ describe('web e2e: macOS window drag coverage', () => {
|
||||
subscribe: () => () => {}, recording: async () => {},
|
||||
edit: async () => ({ status: 'not-ready', snapshot }) },
|
||||
} })
|
||||
const mark = (): void => { document.documentElement.setAttribute('data-platform', value) }
|
||||
const mark = (): void => {
|
||||
document.documentElement.setAttribute('data-platform', value)
|
||||
if (value === 'win32') {
|
||||
document.documentElement.setAttribute('data-windows-titlebar', '')
|
||||
document.documentElement.style.setProperty('--dsh-windows-titlebar-height', '40px')
|
||||
}
|
||||
}
|
||||
if (document.documentElement === null) document.addEventListener('DOMContentLoaded', mark, { once: true })
|
||||
else mark()
|
||||
}, { value: 'darwin', snapshot: initialShortcutConfig() })
|
||||
}, { value: platform, snapshot: initialShortcutConfig() })
|
||||
const tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.goto(host.authenticatedUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
return { page, tripwire }
|
||||
}
|
||||
|
||||
it.each(['darwin', 'win32'] as const)('%s keeps compact Plugins controls below native chrome in both sidebar states', async (platform) => {
|
||||
const { page, tripwire } = await desktopPage(platform)
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 480 })
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package]').first().waitFor()
|
||||
const bar = page.locator('[data-plugin-bar]')
|
||||
const body = page.locator('[data-plugin-body]')
|
||||
const toolbarGeometry = () => bar.evaluate((element) => {
|
||||
const header = element.querySelector('header')!
|
||||
const bounds = element.getBoundingClientRect()
|
||||
const buttons = Array.from(element.querySelectorAll('button'))
|
||||
.filter(button => button.textContent?.trim() === 'Refresh' || button.textContent?.trim() === 'Add plugin')
|
||||
return {
|
||||
top: bounds.top,
|
||||
padding: Number.parseFloat(getComputedStyle(header).paddingTop),
|
||||
buttons: buttons.map((button) => { const rect = button.getBoundingClientRect(); return { x: rect.x, y: rect.y } }),
|
||||
}
|
||||
})
|
||||
for (const collapsed of [false, true]) {
|
||||
if (collapsed) {
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
||||
}
|
||||
await settled(page)
|
||||
await body.evaluate((element) => { element.scrollTop = 0 })
|
||||
await page.waitForFunction(() => !document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
|
||||
const before = await toolbarGeometry()
|
||||
expect(before.top).toBe(platform === 'darwin' ? 0 : 40)
|
||||
expect(before.padding).toBe(platform === 'darwin' ? 76 : 28)
|
||||
expect(before.buttons).toHaveLength(2)
|
||||
await body.evaluate((element) => { element.scrollTop = 120 })
|
||||
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
|
||||
const after = await toolbarGeometry()
|
||||
// macOS only needs the top strip cleared while the sidebar is collapsed; with it open the
|
||||
// native controls live in the sidebar, so the compact bar keeps its own 16px spacing.
|
||||
const compactTop = platform === 'darwin' && collapsed ? 56 : 16
|
||||
expect(after.padding).toBe(before.padding)
|
||||
expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height))).toBe(compactTop + 48)
|
||||
expect(after.buttons).toEqual(before.buttons.map(button => ({ ...button, y: button.y - (before.padding - compactTop) })))
|
||||
expect(after.buttons.every(button => button.y >= (platform === 'darwin' ? compactTop : 16))).toBe(true)
|
||||
expect(await body.evaluate(element => element.getBoundingClientRect().top))
|
||||
.toBe(await bar.evaluate(element => element.getBoundingClientRect().top))
|
||||
if (platform === 'darwin') expect(swallowedBoxes(await collectedRegions(page, INTERACTIVE_SELECTOR))).toEqual([])
|
||||
}
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally { await page.close() }
|
||||
})
|
||||
|
||||
it.each([
|
||||
['darwin', false], ['darwin', true], ['win32', false], ['win32', true],
|
||||
] as const)('%s scopes compact spacing with tabs=%s to the collapsed macOS header at every width', async (platform, hasTabs) => {
|
||||
const host = hasTabs ? await launchWebScaffold({
|
||||
profile: { packages: [], bundles: ['@deepseek-ai/dsh-experimental-plugin-market-bundle'] },
|
||||
}) : scaffold
|
||||
try {
|
||||
const { page, tripwire } = await desktopPage(platform, host)
|
||||
try {
|
||||
await page.setViewportSize({ width: 1280, height: 480 })
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
await page.locator('[data-plugin-package]').first().waitFor()
|
||||
const bar = page.locator('[data-plugin-bar]')
|
||||
const body = page.locator('[data-plugin-body]')
|
||||
const control = bar.locator(hasTabs ? '[role="tablist"]' : '[data-plugin-toolbar]')
|
||||
const top = async () => (await control.boundingBox())!.y - (await bar.boundingBox())!.y
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
||||
for (const width of [1280, 801, 520, 1280]) {
|
||||
await page.setViewportSize({ width, height: 480 })
|
||||
await settled(page)
|
||||
await body.evaluate((element) => { element.scrollTop = 0 })
|
||||
await page.waitForFunction(() => !document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
|
||||
expect(await bar.locator('h1').evaluate(element => element.getBoundingClientRect().top)
|
||||
- (await bar.boundingBox())!.y).toBe(platform === 'darwin' ? 76 : 28)
|
||||
await body.evaluate((element) => { element.scrollTop = 500 })
|
||||
await page.waitForFunction(() => document.querySelector('[data-plugin-bar]')?.hasAttribute('data-compact'))
|
||||
const inset = platform === 'darwin' ? 56 : 16
|
||||
expect(await top()).toBe(inset)
|
||||
expect(await bar.evaluate(element => Number.parseFloat(getComputedStyle(element, '::before').height)))
|
||||
.toBe(inset + (hasTabs ? 34 : 32) + 16)
|
||||
}
|
||||
await page.getByRole('button', { name: 'Open sidebar', exact: true }).last().click()
|
||||
await settled(page)
|
||||
await body.evaluate((element) => { element.scrollTop = 500 })
|
||||
await expect.poll(top).toBe(16)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
} finally { await page.close() }
|
||||
} finally { if (host !== scaffold) await host.close() }
|
||||
})
|
||||
|
||||
it('leaves no interactive box inside the drag surface, and keeps the panel strip row draggable', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
const { page, tripwire } = await desktopPage()
|
||||
try {
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
const agent = scaffold.ctx.agents.list()[0]
|
||||
@@ -338,7 +436,7 @@ describe('web e2e: macOS window drag coverage', () => {
|
||||
}, 180_000)
|
||||
|
||||
it('keeps the Conversation header draggable over the collapsed column, and the seat out of it', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
const { page, tripwire } = await desktopPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
||||
await page.locator('[data-shell-leading]').waitFor({ state: 'visible', timeout: 15_000 })
|
||||
@@ -369,7 +467,7 @@ describe('web e2e: macOS window drag coverage', () => {
|
||||
}, 120_000)
|
||||
|
||||
it('keeps an entry page’s chrome row draggable', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
const { page, tripwire } = await desktopPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
const head = page.locator('[class*="pageHead"]').first()
|
||||
@@ -406,7 +504,7 @@ describe('web e2e: macOS window drag coverage', () => {
|
||||
}, 120_000)
|
||||
|
||||
it('keeps a detail page’s head row draggable and its body out of the drag surface', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
const { page, tripwire } = await desktopPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
|
||||
const cards = page.locator('[data-plugin-package], [data-plugin-item]')
|
||||
@@ -472,7 +570,7 @@ describe('web e2e: macOS window drag coverage', () => {
|
||||
}, 120_000)
|
||||
|
||||
it('keeps a covering overlay out of the drag surface', async () => {
|
||||
const { page, tripwire } = await darwinPage()
|
||||
const { page, tripwire } = await desktopPage()
|
||||
try {
|
||||
await page.getByRole('button', { name: 'Account menu', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"tests/plugin-config.e2e.ts",
|
||||
"tests/input-plugin-extensions.e2e.ts",
|
||||
"tests/plugin-manager.e2e.ts",
|
||||
"tests/plugin-sticky-motion.e2e.ts",
|
||||
"tests/entry-customization.e2e.ts",
|
||||
"tests/cua-optin.e2e.ts",
|
||||
"tests/cua-permissions.e2e.ts",
|
||||
@@ -106,6 +107,7 @@
|
||||
"tests/plugin-install-registry.e2e.ts",
|
||||
"tests/plugin-install-github.e2e.ts",
|
||||
"tests/client-plugin-live.e2e.ts",
|
||||
"tests/icon-skin.e2e.ts",
|
||||
"tests/draft-initialization.e2e.ts",
|
||||
"tests/settings-chrome.e2e.ts",
|
||||
"tests/session-log-upload.e2e.ts",
|
||||
|
||||
@@ -242,8 +242,11 @@ export default defineConfig({
|
||||
// through its own package.json exports from the importer's directory, and
|
||||
// CSS still rides Vite's pipeline because the client build preset emits it
|
||||
// beside the bundle. Plugin packages never enter this graph; they arrive as
|
||||
// runtime bundles through the client module system. The remaining alias
|
||||
// browserizes the vendored Cordis Loader's only Node import.
|
||||
// runtime bundles through the client module system. The node:module alias
|
||||
// supports Loader initialization; its filesystem resolver's other Node
|
||||
// imports must stay default imports for Vite's browser-external stubs.
|
||||
// Browser boot supplies loader.internal before importing plugin entries,
|
||||
// so the filesystem resolver is never invoked in the browser.
|
||||
alias: [
|
||||
{ find: /^node:module$/, replacement: src('./src/node-module-stub.ts') },
|
||||
],
|
||||
|
||||
+16
-7
@@ -1610,7 +1610,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-host-frontend-static`
|
||||
|
||||
- `inject`: `webServer` · `connection`
|
||||
- `source`: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
|
||||
- `source`: [`packages/host/frontend-static/src/index.ts:34`](../packages/host/frontend-static/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the dist anchor. */
|
||||
@@ -1695,7 +1695,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:97`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2472,7 +2472,7 @@ export interface PlanModeConfig {
|
||||
|
||||
- `inject`: `loader` · `profileContext`
|
||||
- `refs`: [`NpmFileSource`](subsystems/boot.md)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:51`](../packages/boot/plugin-manager/src/index.ts)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:52`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
|
||||
@@ -2487,6 +2487,8 @@ export interface Config {
|
||||
inspectTimeoutMs?: number
|
||||
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
|
||||
githubConnectionTimeoutMs?: number
|
||||
/** Maximum duration of each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. */
|
||||
gitTransportTimeoutMs?: number
|
||||
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
|
||||
idleTimeoutMs?: number
|
||||
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
|
||||
@@ -3945,7 +3947,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-jobs`
|
||||
|
||||
- `inject`: `tools` · `jobs` · `systemPrompt`
|
||||
- `source`: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
|
||||
- `source`: [`packages/jobs/tool-jobs/src/index.ts:43`](../packages/jobs/tool-jobs/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configures bounded `job_output` waits and completion-notice delivery. */
|
||||
@@ -3965,6 +3967,13 @@ export interface Config {
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
/**
|
||||
* Largest complete completion notice, in UTF-8 bytes, that carries the
|
||||
* job's output and final result inline (default 2048); a larger one, or one
|
||||
* over the producer's `outputLimitBytes`, leaves them for `job_output`. 0
|
||||
* never inlines.
|
||||
*/
|
||||
inlineOutputBytes?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4268,7 +4277,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
- `inject`: `tools` · `web` · `systemPrompt`
|
||||
- `source`: [`packages/web/tool-web/src/index.ts:37`](../packages/web/tool-web/src/index.ts)
|
||||
- `source`: [`packages/web/tool-web/src/index.ts:39`](../packages/web/tool-web/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
|
||||
@@ -4458,7 +4467,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:34`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:36`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Settings read by the next search or fetch without re-registering either provider. */
|
||||
@@ -4509,7 +4518,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
|
||||
@@ -1612,7 +1612,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-host-frontend-static`
|
||||
|
||||
- `inject`: `webServer` · `connection`
|
||||
- `source`: [`packages/host/frontend-static/src/index.ts:30`](../packages/host/frontend-static/src/index.ts)
|
||||
- `source`: [`packages/host/frontend-static/src/index.ts:34`](../packages/host/frontend-static/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the dist anchor. */
|
||||
@@ -1697,7 +1697,7 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-host-webserver`
|
||||
|
||||
- `source`: [`packages/host/webserver/src/index.ts:79`](../packages/host/webserver/src/index.ts)
|
||||
- `source`: [`packages/host/webserver/src/index.ts:97`](../packages/host/webserver/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Web server listen, TLS, and response-compression config. */
|
||||
@@ -2474,7 +2474,7 @@ export interface PlanModeConfig {
|
||||
|
||||
- `inject`: `loader` · `profileContext`
|
||||
- `refs`: [`NpmFileSource`](subsystems/boot.zh.md)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:51`](../packages/boot/plugin-manager/src/index.ts)
|
||||
- `source`: [`packages/boot/plugin-manager/src/index.ts:52`](../packages/boot/plugin-manager/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
|
||||
@@ -2489,6 +2489,8 @@ export interface Config {
|
||||
inspectTimeoutMs?: number
|
||||
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
|
||||
githubConnectionTimeoutMs?: number
|
||||
/** Maximum duration of each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. */
|
||||
gitTransportTimeoutMs?: number
|
||||
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
|
||||
idleTimeoutMs?: number
|
||||
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
|
||||
@@ -3947,7 +3949,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-jobs`
|
||||
|
||||
- `inject`: `tools` · `jobs` · `systemPrompt`
|
||||
- `source`: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
|
||||
- `source`: [`packages/jobs/tool-jobs/src/index.ts:43`](../packages/jobs/tool-jobs/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Configures bounded `job_output` waits and completion-notice delivery. */
|
||||
@@ -3967,6 +3969,13 @@ export interface Config {
|
||||
* the next user input.
|
||||
*/
|
||||
maxConsecutiveWakes?: number
|
||||
/**
|
||||
* Largest complete completion notice, in UTF-8 bytes, that carries the
|
||||
* job's output and final result inline (default 2048); a larger one, or one
|
||||
* over the producer's `outputLimitBytes`, leaves them for `job_output`. 0
|
||||
* never inlines.
|
||||
*/
|
||||
inlineOutputBytes?: number
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4270,7 +4279,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-tool-web`
|
||||
|
||||
- `inject`: `tools` · `web` · `systemPrompt`
|
||||
- `source`: [`packages/web/tool-web/src/index.ts:37`](../packages/web/tool-web/src/index.ts)
|
||||
- `source`: [`packages/web/tool-web/src/index.ts:39`](../packages/web/tool-web/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: which web tools to register, search bounds, per-tool budgets, and the fetch output cap. */
|
||||
@@ -4460,7 +4469,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:34`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
- `source`: [`packages/web/web-deepseek-api/src/index.ts:36`](../packages/web/web-deepseek-api/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Settings read by the next search or fetch without re-registering either provider. */
|
||||
@@ -4511,7 +4520,7 @@ export interface Config {
|
||||
|
||||
- `inject`: `web`
|
||||
- `refs`: `Volatile` (`@deepseek-ai/cordis`)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:49`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
- `source`: [`packages/web/web-search-deepseek/src/index.ts:51`](../packages/web/web-search-deepseek/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config (all optional — `apply` fills env-var and constant defaults). */
|
||||
|
||||
@@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -83,7 +83,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -85,7 +85,7 @@
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:42`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
|
||||
|
||||
@@ -250,7 +250,9 @@ Manage profile files and apply their declared reload lifecycle.
|
||||
/**
|
||||
* Install a package using the same pnpm implementation as dsh plugin. GitHub
|
||||
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
|
||||
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
|
||||
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An
|
||||
* HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs
|
||||
* find that only SSH can read it. A run
|
||||
* that fails, is cancelled, or adds a package without a bundle patch restores
|
||||
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
|
||||
* @param spec One package spec, including local paths relative to the invocation directory.
|
||||
|
||||
@@ -250,7 +250,9 @@ Manage profile files and apply their declared reload lifecycle.
|
||||
/**
|
||||
* Install a package using the same pnpm implementation as dsh plugin. GitHub
|
||||
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
|
||||
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
|
||||
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An
|
||||
* HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs
|
||||
* find that only SSH can read it. A run
|
||||
* that fails, is cancelled, or adds a package without a bundle patch restores
|
||||
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
|
||||
* @param spec One package spec, including local paths relative to the invocation directory.
|
||||
|
||||
@@ -156,7 +156,9 @@ clientPath(id: string): string | undefined
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* its body holds the file open until the caller consumes or cancels it. A
|
||||
* bundle, map, or chunk body is the held buffer itself, not a copy, so a
|
||||
* caller must not modify its bytes.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
|
||||
@@ -156,7 +156,9 @@ clientPath(id: string): string | undefined
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* its body holds the file open until the caller consumes or cancels it. A
|
||||
* bundle, map, or chunk body is the held buffer itself, not a copy, so a
|
||||
* caller must not modify its bytes.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/web-server.md
|
||||
/http-server:
|
||||
en: 56b268a69964f651
|
||||
zh: c8ac8f16bf58a98a
|
||||
en: a4861c68f3eb0b03
|
||||
zh: a3da9f0467fdee02
|
||||
/http-server/routes:
|
||||
en: e949c3738a35b58f
|
||||
zh: 138e3aaeb319ddc4
|
||||
en: 57adf92882ff0f77
|
||||
zh: d17992cca18cc790
|
||||
/http-server/config:
|
||||
en: 4b162bd9f280eb06
|
||||
zh: 3bea98dfee88c582
|
||||
/http-server/the-service:
|
||||
en: bbf84a6cd0460dc2
|
||||
zh: cc63ce45b0675c6d
|
||||
en: d1922a5e809e20c3
|
||||
zh: 5c1772c81a19784c
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](web-server.zh.md)
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback handler that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
[dsh-host-webserver](../../packages/host/webserver) is the browser HTTP carrier for the GUI host: one `node:http` server — `node:https` when `tls` is configured — providing `ctx.webServer`, a named-route registry, optional gzip response compression, index.html transform callbacks, and one fallback, a handler or a resource, that a plugin may claim. It is not part of the agent loop and not a capability seam; its only harness concept is the Peer an index render speaks for, and another plugin registers every feature route, including the `/api` bridge, plugin bundles, and the HMR event stream ([layering reference](../../packages/boot/app-boot/README.md)). It serves browsers only: Electron loads the built files over `file://` and sends fetch requests through an IPC bridge instead of this server.
|
||||
|
||||
Source: [`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -24,8 +24,30 @@ interface WebRoute {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Answers one GET or HEAD request with a Fetch response. An HTTP request
|
||||
* arrives without a Peer and carries the client's headers; an in-process
|
||||
* {@link WebServer.readResource} read passes the Peer its caller admitted.
|
||||
*/
|
||||
type WebResource = (request: Request, peer?: PeerScope) => Promise<Response>
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One named route answered by a resource instead of a handler. */
|
||||
interface WebResourceRoute {
|
||||
kind: WebRouteKind
|
||||
/** Absolute pathname, no trailing slash. */
|
||||
path: string
|
||||
/** Answers HTTP GET and HEAD requests and in-process reads. */
|
||||
resource: WebResource
|
||||
}
|
||||
```
|
||||
|
||||
Match order is fixed: exact table first, then longest matching prefix, then the registered fallback. Registration order carries no request-facing semantics — named routes are composed to be disjoint, and the fallback seat answers anything no named route claims; one owner only, a second registration throws. The shipped Web composition claims the seat with [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts), the SPA dist server with locked semantics: Connection authenticates the dist root and configured index before their HTML is read; non-index assets remain public; non-GET/HEAD is 405, traversal outside the dist root is 403, existing files are served directly, absent or non-file targets are empty 404 responses, and unknown extensions ship as octet-stream.
|
||||
|
||||
A route or the fallback may give a resource, a function from a Fetch `Request` to a `Response`, instead of a handler. HTTP `GET` and `HEAD` requests reach it without a Peer, and `readResource(request, peer)` reads it in process for the Peer its caller admitted; a read follows the same match order but reaches only resources, so a route that returns owner-private data or performs an action stays a handler even when it answers `GET`.
|
||||
|
||||
<a id="config"></a>
|
||||
|
||||
## Config
|
||||
@@ -81,7 +103,7 @@ No composition can bind every interface at once: the unspecified address is reje
|
||||
|
||||
## The service
|
||||
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so route handlers retain direct `ServerResponse` ownership and no response-writing API is added to the service. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
`WebServer` (`ctx.webServer`) listens immediately on activation; a listen failure (EADDRINUSE…) rejects initialization, and the boot process reports the failed fiber. The `tls` pair is read once before the listener binds, and an unreadable, empty, or mismatched pair rejects initialization the same way instead of falling back to HTTP. `register(route)` adds one named route and returns its disposer; a duplicate `(kind, path)` throws because route patterns are a composition-level contract and a collision is a misconfiguration. Gzip wraps eligible socket-backed responses inside the server, so handlers keep direct `ServerResponse` ownership, and a resource hands the service a `Response` to write back. Existing content encodings, `Cache-Control: no-transform`, ranges, SSE, ZIP, and the packaged `.gz` Worker image remain identity responses. `collectIndexInjections(peer)` gathers structured `IndexInjection` rows over one `webserver/index-inject` emit that passes the Peer the page speaks for, and `renderIndex(html, peer)` renders them into successful root and configured index responses before applying the raw `tapIndex(transform)` escape-hatch transforms in registration order; [dsh-client-modules](../../packages/client/modules) answers the event with the boot manifest rows. `port` reads the listening port, including the port assigned by the OS when `config.port` is 0, and `protocol` reports the scheme the listener serves (`http:` or `https:`).
|
||||
|
||||
A request whose handling throws (a malformed %-escape hitting `decodeURIComponent`, a client dropping mid-body) is logged as a warning and answered 400 — or the socket destroyed when headers are already out — never a process exit. Disposal pairs `close()` with `closeAllConnections()` because a handler may hold its response open (SSE) and such connections never end on their own; without the force-close, teardown would hang. A TLS socket counts as a connection only after its handshake, so the server tracks raw TLS sockets beside upgraded ones and destroys both at disposal. The package never prints: the URL line belongs to the shell. Per-package operational detail, including the dev-mode bundle watch pipeline, stays in the [README](../../packages/host/webserver/README.md).
|
||||
|
||||
@@ -180,7 +202,7 @@ Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/s
|
||||
|
||||
### `ctx.webServer` — `WebServer`
|
||||
|
||||
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.
|
||||
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and a request no named route matches is answered 404 until the fallback owner registers.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -194,10 +216,10 @@ getOrigin(): string
|
||||
/**
|
||||
* Register a named route. Duplicate (kind, path) throws — route patterns are
|
||||
* a composition-level contract, so a collision is a misconfiguration.
|
||||
* @param route - kind, path, and the owning handler.
|
||||
* @param route - kind, path, and the owning handler or resource.
|
||||
* @returns the disposer removing the route.
|
||||
*/
|
||||
register(route: WebRoute): () => void
|
||||
register(route: WebRoute | WebResourceRoute): () => void
|
||||
|
||||
/**
|
||||
* Register an exact-path HTTP upgrade route. Duplicate paths throw because
|
||||
@@ -208,14 +230,32 @@ register(route: WebRoute): () => void
|
||||
registerUpgrade(route: WebUpgradeRoute): () => void
|
||||
|
||||
/**
|
||||
* Claim the fallback seat: the handler answering every request no named
|
||||
* route matches (the SPA dist server in the shipped Web composition). One
|
||||
* owner only — a second registration throws, because two fallbacks cannot
|
||||
* compose.
|
||||
* @param handler - owns the full response lifecycle of unmatched requests.
|
||||
* Claim the fallback seat: the handler or resource answering every request
|
||||
* no named route matches (the SPA dist server in the shipped Web
|
||||
* composition). One owner only — a second registration throws, because two
|
||||
* fallbacks cannot compose.
|
||||
* @param fallback - a handler owning the full response lifecycle of
|
||||
* unmatched requests, or `{ resource }` answering them.
|
||||
* @returns the disposer releasing the seat.
|
||||
*/
|
||||
registerFallback(handler: WebRoute['handler']): () => void
|
||||
registerFallback(fallback: WebRoute['handler'] | Pick<WebResourceRoute, 'resource'>): () => void
|
||||
|
||||
/**
|
||||
* Read the response an HTTP request for the same URL would receive, as the
|
||||
* resource computes it for `peer`, without an HTTP exchange. The request
|
||||
* path picks the responder by the HTTP match order — exact, longest prefix,
|
||||
* then the fallback — and only a resource answers. This method checks no
|
||||
* cookie, Host, or Origin: the caller admits the reader before reading.
|
||||
* @param request - the read; its URL path selects the responder.
|
||||
* @param peer - the Peer the read speaks for, passed to the resource.
|
||||
* @returns the resource's response, without a body for HEAD as over HTTP;
|
||||
* 405 with `Allow: GET, HEAD` for any other method, checked before matching,
|
||||
* where over HTTP a handler route or an unmatched path answers such a
|
||||
* method itself; 404 when the path matches a handler route, or reaches the
|
||||
* fallback seat while it is unclaimed or holds a handler. A resource that
|
||||
* throws rejects the read, where an HTTP request would receive 400.
|
||||
*/
|
||||
async readResource(request: Request, peer: PeerScope): Promise<Response>
|
||||
|
||||
/**
|
||||
* Register a raw-HTML index transform, the escape hatch for markup no
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](web-server.md) | 中文
|
||||
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退处理器。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
[dsh-host-webserver](../../packages/host/webserver) 是 GUI Host 的浏览器 HTTP 载体:一个 `node:http` 服务器——配置 `tls` 时改用 `node:https`——提供 `ctx.webServer`、具名路由注册表、可选的 gzip 响应压缩、index.html 转换回调,以及一个可由插件认领的回退(handler 或 resource)。它不属于 agent loop(智能体循环),也不是能力 seam;它唯一涉及的 harness 概念是 index 渲染所代表的 Peer。其他插件负责注册所有功能路由,包括 `/api` 桥接、插件 bundle 和 HMR(热模块替换)事件流([分层说明](../../packages/boot/app-boot/README.zh.md))。该服务器只服务浏览器:Electron 通过 `file://` 加载已构建文件,并经 IPC 桥接发送 fetch 请求,不使用本服务器。
|
||||
|
||||
源码:[`packages/host/webserver/src/index.ts`](../../packages/host/webserver/src/index.ts)
|
||||
|
||||
@@ -24,8 +24,30 @@ interface WebRoute {
|
||||
}
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/**
|
||||
* Answers one GET or HEAD request with a Fetch response. An HTTP request
|
||||
* arrives without a Peer and carries the client's headers; an in-process
|
||||
* {@link WebServer.readResource} read passes the Peer its caller admitted.
|
||||
*/
|
||||
type WebResource = (request: Request, peer?: PeerScope) => Promise<Response>
|
||||
```
|
||||
|
||||
```ts type-equiv
|
||||
/** One named route answered by a resource instead of a handler. */
|
||||
interface WebResourceRoute {
|
||||
kind: WebRouteKind
|
||||
/** Absolute pathname, no trailing slash. */
|
||||
path: string
|
||||
/** Answers HTTP GET and HEAD requests and in-process reads. */
|
||||
resource: WebResource
|
||||
}
|
||||
```
|
||||
|
||||
匹配顺序固定:先查 exact 表,再取最长匹配前缀,最后落到已注册的回退。注册顺序不携带任何面向请求的语义:具名路由在组合上互不相交,任何未被具名路由认领的请求都由回退席位应答;席位只有一个所有者,第二次注册会抛出异常。发布的 Web 组合用 [`dsh-host-frontend-static`](../../packages/host/frontend-static/src/index.ts) 认领席位,即遵循固定语义的 SPA dist 服务器:Connection 在读取 dist 根目录和配置 index 的 HTML 前完成认证;非 index 资产保持公开;非 GET/HEAD 返回 405,越出 dist 根目录的遍历返回 403,现有文件直接提供,缺失或不是文件的目标返回空的 404,未知扩展名按 octet-stream 发送。
|
||||
|
||||
路由或回退可以用 resource——一个把 Fetch `Request` 映射为 `Response` 的函数——代替 handler。HTTP 的 `GET`、`HEAD` 请求不带 Peer 到达它,`readResource(request, peer)` 则在进程内按调用方准入的 Peer 读取它;读取遵循同样的匹配顺序但只到达 resource,因此返回 owner 私有数据或会执行操作的路由即使应答 `GET` 也保持为 handler。
|
||||
|
||||
<a id="config"></a>
|
||||
|
||||
## 配置
|
||||
@@ -81,7 +103,7 @@ interface TlsConfig {
|
||||
|
||||
## 服务
|
||||
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 route handler 继续直接持有 `ServerResponse`,服务也不新增响应写出 API。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
`WebServer`(`ctx.webServer`)在激活时立即监听;监听失败(EADDRINUSE 等)会使初始化被拒绝,启动进程会报告失败的 fiber。`tls` 证书对在监听器绑定前读取一次;文件不可读、为空或二者不匹配时同样会使初始化被拒绝,而不是回退到 HTTP。`register(route)` 添加一条具名路由并返回其 disposer;重复的 `(kind, path)` 抛出异常,因为路由模式是组合层约定,冲突即配置错误。Gzip 在服务器内部包装符合条件且基于 socket 的响应,因此 handler 继续直接持有 `ServerResponse`,resource 则把 `Response` 交给服务写回。已有内容编码、`Cache-Control: no-transform`、范围响应、SSE、ZIP 与打包后的 `.gz` Worker 镜像均保持 identity 响应。`collectIndexInjections(peer)` 经一次传入页面所代表 Peer 的 `webserver/index-inject` emit 收集结构化 `IndexInjection` 行,`renderIndex(html, peer)` 把它们渲染进成功的根路径和配置 index 响应,随后再按注册顺序应用原始的 `tapIndex(transform)` 逃生口转换;[dsh-client-modules](../../packages/client/modules) 以启动 manifest(元数据清单)行回应该事件。`port` 读取监听端口,包括 `config.port` 为 0 时操作系统分配的端口,`protocol` 则报告监听器提供的协议(`http:` 或 `https:`)。
|
||||
|
||||
处理过程中抛出异常的请求(畸形的 % 转义撞上 `decodeURIComponent`、客户端在请求体中途断开)会记录为警告并应答 400(响应头已发出时则销毁 socket),绝不导致进程退出。dispose(资源释放)把 `close()` 与 `closeAllConnections()` 配对使用,因为处理器可能像 SSE(Server-Sent Events)那样保持响应打开,而这类连接永远不会自行结束;没有强制关闭,拆卸就会挂起。TLS socket 只有完成握手后才计入连接,因此服务器会把原始 TLS socket 与升级后的 socket 一并跟踪,并在 dispose 时销毁两者。该包从不打印输出:URL 行归 shell 所有。逐包运维细节(含开发模式的 bundle 监视流水线)留在 [README](../../packages/host/webserver/README.zh.md) 中。
|
||||
|
||||
@@ -180,7 +202,7 @@ Source: [`packages/bundle/web-app/src/index.ts`](../../packages/bundle/web-app/s
|
||||
|
||||
### `ctx.webServer` — `WebServer`
|
||||
|
||||
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and the fallback handler answers anything not yet claimed during startup with 404 until its owner registers.
|
||||
The browser HTTP carrier service. Activation loads any configured TLS material, then listens immediately; a material or listen failure rejects initialization, and the boot process reports the failed fiber. Route registration order does not affect requests because configured named routes must be distinct, and a request no named route matches is answered 404 until the fallback owner registers.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
@@ -194,10 +216,10 @@ getOrigin(): string
|
||||
/**
|
||||
* Register a named route. Duplicate (kind, path) throws — route patterns are
|
||||
* a composition-level contract, so a collision is a misconfiguration.
|
||||
* @param route - kind, path, and the owning handler.
|
||||
* @param route - kind, path, and the owning handler or resource.
|
||||
* @returns the disposer removing the route.
|
||||
*/
|
||||
register(route: WebRoute): () => void
|
||||
register(route: WebRoute | WebResourceRoute): () => void
|
||||
|
||||
/**
|
||||
* Register an exact-path HTTP upgrade route. Duplicate paths throw because
|
||||
@@ -208,14 +230,32 @@ register(route: WebRoute): () => void
|
||||
registerUpgrade(route: WebUpgradeRoute): () => void
|
||||
|
||||
/**
|
||||
* Claim the fallback seat: the handler answering every request no named
|
||||
* route matches (the SPA dist server in the shipped Web composition). One
|
||||
* owner only — a second registration throws, because two fallbacks cannot
|
||||
* compose.
|
||||
* @param handler - owns the full response lifecycle of unmatched requests.
|
||||
* Claim the fallback seat: the handler or resource answering every request
|
||||
* no named route matches (the SPA dist server in the shipped Web
|
||||
* composition). One owner only — a second registration throws, because two
|
||||
* fallbacks cannot compose.
|
||||
* @param fallback - a handler owning the full response lifecycle of
|
||||
* unmatched requests, or `{ resource }` answering them.
|
||||
* @returns the disposer releasing the seat.
|
||||
*/
|
||||
registerFallback(handler: WebRoute['handler']): () => void
|
||||
registerFallback(fallback: WebRoute['handler'] | Pick<WebResourceRoute, 'resource'>): () => void
|
||||
|
||||
/**
|
||||
* Read the response an HTTP request for the same URL would receive, as the
|
||||
* resource computes it for `peer`, without an HTTP exchange. The request
|
||||
* path picks the responder by the HTTP match order — exact, longest prefix,
|
||||
* then the fallback — and only a resource answers. This method checks no
|
||||
* cookie, Host, or Origin: the caller admits the reader before reading.
|
||||
* @param request - the read; its URL path selects the responder.
|
||||
* @param peer - the Peer the read speaks for, passed to the resource.
|
||||
* @returns the resource's response, without a body for HEAD as over HTTP;
|
||||
* 405 with `Allow: GET, HEAD` for any other method, checked before matching,
|
||||
* where over HTTP a handler route or an unmatched path answers such a
|
||||
* method itself; 404 when the path matches a handler route, or reaches the
|
||||
* fallback seat while it is unclaimed or holds a handler. A resource that
|
||||
* throws rejects the read, where an HTTP request would receive 400.
|
||||
*/
|
||||
async readResource(request: Request, peer: PeerScope): Promise<Response>
|
||||
|
||||
/**
|
||||
* Register a raw-HTML index transform, the escape hatch for markup no
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: b96bcdf42ced9eab
|
||||
zh: aa927e2450238557
|
||||
/web-access/provider-availability:
|
||||
en: da78eb96d684ad47
|
||||
zh: 4b726cc04c92ef13
|
||||
en: 38515cfa348e0950
|
||||
zh: 890b624c15d8aa41
|
||||
/web-access/fetch-network-policy:
|
||||
en: 6851874be5841d90
|
||||
zh: dc085bf3b82d77df
|
||||
|
||||
@@ -125,6 +125,8 @@ type WebFetchBody =
|
||||
|
||||
A provider's `available(): boolean` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id or ambiguous candidate set) in its code and message.
|
||||
|
||||
A search or fetch provider may also implement `ready(llmProvider): Promise<boolean>`, a configuration-only check that must not make network calls: it answers whether an operation made for a model request on that chat route has a credential to send. `searchReady(llmProvider?)` and `fetchReady(llmProvider?)` ask only the provider that `search()` or `fetch()` would select, treat a provider without `ready()` as ready, and answer true when selection fails, because the operation reports that failure as a `WebError`. Readiness is not a selection input; `dsh-tool-web` makes it the activation condition of `web_search` and `web_fetch`, so a model step whose route answers false neither declares nor dispatches the tool.
|
||||
|
||||
Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. Each operation reads the configured id when it runs, so a saved settings change applies to the next search or fetch without remounting `ctx.web`.
|
||||
|
||||
## Fetch network policy
|
||||
@@ -194,6 +196,16 @@ registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
*/
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
|
||||
/**
|
||||
* Whether the provider `search()` would select can authenticate a search made for a
|
||||
* model request on the `llmProvider` chat route. Asks only that provider's `ready()`;
|
||||
* one without `ready()` counts as ready. Makes no network calls. A selection failure
|
||||
* answers true, because `search()` reports that failure as a structured `WebError`.
|
||||
* @param llmProvider - the provider route id of the model request, when known.
|
||||
* @returns false only when the selected provider reports that it cannot authenticate.
|
||||
*/
|
||||
async searchReady(llmProvider?: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Retrieve one URL through the selected provider. Resolves the provider at
|
||||
* call time with the selection rules above; throws {@link WebError} when the
|
||||
@@ -203,6 +215,16 @@ async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearch
|
||||
* @returns the retrieval outcome; non-2xx responses resolve descriptively.
|
||||
*/
|
||||
async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
|
||||
/**
|
||||
* Whether the provider `fetch()` would select can authenticate a fetch made for a
|
||||
* model request on the `llmProvider` chat route. Asks only that provider's `ready()`;
|
||||
* one without `ready()` counts as ready. Makes no network calls. A selection failure
|
||||
* answers true, because `fetch()` reports that failure as a structured `WebError`.
|
||||
* @param llmProvider - the provider route id of the model request, when known.
|
||||
* @returns false only when the selected provider reports that it cannot authenticate.
|
||||
*/
|
||||
async fetchReady(llmProvider?: string): Promise<boolean>
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)
|
||||
|
||||
@@ -125,6 +125,8 @@ type WebFetchBody =
|
||||
|
||||
提供方的 `available(): boolean` 是一个廉价的本地检查(凭证是否存在、配置是否可解析),**禁止发起网络调用**。它是执行时选择提供方的输入,而不是健康检查系统:`search()`/`fetch()` 会读取它来选择可用的提供方。选择失败时,调用方会收到可据以分支处理的结构化 `WebError`;其错误代码和消息会说明缺失的 id 或存在歧义的候选集。
|
||||
|
||||
搜索或抓取提供方还可以实现 `ready(llmProvider): Promise<boolean>`,这是只检查配置、禁止发起网络调用的判断:它回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。`searchReady(llmProvider?)` 与 `fetchReady(llmProvider?)` 只询问 `search()` 或 `fetch()` 会选中的提供方,把没有 `ready()` 的提供方视为就绪,并在选择失败时回答 true,因为对应操作会把该失败报告为 `WebError`。就绪判断不是选择输入;`dsh-tool-web` 把它作为 `web_search` 与 `web_fetch` 的激活条件,因此路由回答 false 的模型步骤既不声明也不派发该工具。
|
||||
|
||||
选择从不依赖注册顺序、配置顺序或 HMR(热模块替换)顺序:一项能力要么有显式的提供方 id(配置 `searchProvider`/`fetchProvider`,或填充同一字段的对应环境变量),要么在恰好只有一个可用提供方注册时自动选择;如果存在多个可用提供方却未配置 id,则抛出 `WEB_PROVIDER_AMBIGUOUS`,而不会选用最先注册的提供方。每次操作在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `ctx.web` 即对下一次搜索或抓取生效。
|
||||
|
||||
## 抓取网络策略
|
||||
@@ -194,6 +196,16 @@ registerFetchProvider(provider: WebFetchProvider): () => void
|
||||
*/
|
||||
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
|
||||
|
||||
/**
|
||||
* Whether the provider `search()` would select can authenticate a search made for a
|
||||
* model request on the `llmProvider` chat route. Asks only that provider's `ready()`;
|
||||
* one without `ready()` counts as ready. Makes no network calls. A selection failure
|
||||
* answers true, because `search()` reports that failure as a structured `WebError`.
|
||||
* @param llmProvider - the provider route id of the model request, when known.
|
||||
* @returns false only when the selected provider reports that it cannot authenticate.
|
||||
*/
|
||||
async searchReady(llmProvider?: string): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Retrieve one URL through the selected provider. Resolves the provider at
|
||||
* call time with the selection rules above; throws {@link WebError} when the
|
||||
@@ -203,6 +215,16 @@ async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearch
|
||||
* @returns the retrieval outcome; non-2xx responses resolve descriptively.
|
||||
*/
|
||||
async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
|
||||
|
||||
/**
|
||||
* Whether the provider `fetch()` would select can authenticate a fetch made for a
|
||||
* model request on the `llmProvider` chat route. Asks only that provider's `ready()`;
|
||||
* one without `ready()` counts as ready. Makes no network calls. A selection failure
|
||||
* answers true, because `fetch()` reports that failure as a structured `WebError`.
|
||||
* @param llmProvider - the provider route id of the model request, when known.
|
||||
* @returns false only when the selected provider reports that it cannot authenticate.
|
||||
*/
|
||||
async fetchReady(llmProvider?: string): Promise<boolean>
|
||||
```
|
||||
|
||||
Source: [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)
|
||||
|
||||
@@ -240,8 +240,8 @@
|
||||
en: 207228068bc61885
|
||||
zh: 36e9344ee1af8869
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent/subagent:
|
||||
en: 3d5e1f7fa25fe7e5
|
||||
zh: c30b85ab47b3124d
|
||||
en: 430672da3c1a6ca6
|
||||
zh: bd3a9ab4089f9dbb
|
||||
/tool-schema-catalog/deepseek-ai-dsh-tool-subagent-control:
|
||||
en: ab82e130c3413f04
|
||||
zh: ab82e130c3413f04
|
||||
|
||||
@@ -2136,7 +2136,7 @@ Source: [`packages/subagent/tool-subagent/src/list-models.ts`](../packages/subag
|
||||
|
||||
### `subagent`
|
||||
|
||||
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. If you are still online when it finishes, the runtime automatically delivers its completion notice and any final answer. Use `send_message` to steer it while running or continue its conversation after it finishes.
|
||||
Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. This tool starts an independently managed subagent and immediately returns its id. If you are still online when it finishes, the runtime automatically delivers its completion notice. The notice includes the final answer unless the child sent you a message just before finishing. Use `send_message` to steer it while running or continue its conversation after it finishes.
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -2142,7 +2142,7 @@ subagent_oar 工具在为外部提供方配置的别名之间选择。本目录
|
||||
|
||||
### `subagent`
|
||||
|
||||
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。如果它结束时你仍在线,运行时会自动递送其完成通知及已有的最终回答。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。
|
||||
将一项自包含任务委派给 subagent(在自身上下文中工作的独立 agent),用它卸载聚焦且独立的工作,例如研究、限定范围的实现或分析,以免消耗当前对话的上下文。subagent 会返回结果,但不会返回中间步骤。此工具启动独立管理的 subagent 并立即返回其 id。如果它结束时你仍在线,运行时会自动递送其完成通知。通知包含最终回答,除非子任务在结束前刚给你发过消息。你可以使用 `send_message` 在运行时引导它,或在结束后继续对话。
|
||||
|
||||
```json
|
||||
{
|
||||
|
||||
@@ -153,7 +153,7 @@ class FakeConnectionService extends Service {
|
||||
|
||||
function fakeHttpServer(routes: WebRoute[]): Pick<WebServer, 'register' | 'tapIndex' | 'port'> {
|
||||
return {
|
||||
register(route) {
|
||||
register(route: WebRoute) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
|
||||
@@ -15,14 +15,14 @@
|
||||
en: 87679c51aa3693f5
|
||||
zh: 11684a48a5806afb
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package:
|
||||
en: 425473b242360a78
|
||||
zh: bd91b346ab3d7a78
|
||||
en: 7ec407151aa14244
|
||||
zh: 7f09ed0329449c4d
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package/version-compatibility-and-exemptions:
|
||||
en: 483285658e267dd5
|
||||
zh: 846bec2daac59a96
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package/configuration:
|
||||
en: 64089ca8084f5e0f
|
||||
zh: 8328a53cd6420a82
|
||||
en: ccec56c785b3ca28
|
||||
zh: b945f933888fc7cb
|
||||
/deepseek-ai-dsh-plugin-manager/use-this-package/bundle-detail-documents:
|
||||
en: 99b084ff497011d2
|
||||
zh: 6c047796c1991784
|
||||
@@ -48,8 +48,8 @@
|
||||
en: c29f60be7f3edc1a
|
||||
zh: d9b9388b96ef95cc
|
||||
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work:
|
||||
en: eeeaa73140fa45d7
|
||||
zh: 12b5c10f0390310e
|
||||
en: d26075545fe1e990
|
||||
zh: 068c07d642843c94
|
||||
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
|
||||
en: c670eb2330b441e8
|
||||
zh: 2c8223e03e1e3ef0
|
||||
|
||||
@@ -57,6 +57,8 @@ Official on-demand installs call the same `installBundle` operation as other bun
|
||||
|
||||
`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation.
|
||||
|
||||
An HTTP(S) Git URL on a host for which pnpm has no HTTPS-to-SSH fallback, that is, any host `hosted-git-info` does not know, such as a self-hosted GitLab, is probed with `git ls-remote` before pnpm starts. The probe uses the installer's Git configuration and credential helpers with Git's terminal and askpass prompts disabled; on POSIX it also has no controlling terminal, so SSH cannot prompt. When HTTP(S) cannot read the repository and `ssh://git@<host>/<path>` can, pnpm installs that SSH URL with the original fragment, and the profile records it. Otherwise the spec is installed unchanged. Each probe is bounded by `gitTransportTimeoutMs`, 5000 ms by default; cancellation and manager disposal stop it. URLs with credentials or a query skip the probe.
|
||||
|
||||
Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale.
|
||||
|
||||
The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries.
|
||||
@@ -65,7 +67,7 @@ The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFIC
|
||||
|
||||
`waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation.
|
||||
|
||||
When pnpm 11 blocks dependency scripts, the failed installation reports every pending package name in the profile under `pendingBuilds`, including names left by earlier attempts and names the run reported as ignored. pnpm writes those entries into `pnpm-workspace.yaml` only when it can prompt, so the manager records the reported names itself; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by package name in this profile, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice.
|
||||
When pnpm 11 blocks dependency scripts, the failed installation reports every pending `allowBuilds` key in the profile under `pendingBuilds`, including keys left by earlier attempts, names the run reported as ignored, and the key pnpm suggests for a git-hosted package it refused to prepare. A key whose git URL carries a credential (a password, or a user name over HTTP(S)) is not recorded, so that installation offers only manual approval. pnpm writes those entries into `pnpm-workspace.yaml` only when it can prompt, so the manager records the reported names itself; a failed run restores `package.json` and `pnpm-lock.yaml` but deliberately not `pnpm-workspace.yaml`. The Web plugin page offers **Allow these scripts and retry**; the tool can grant permission on the user's behalf through `approvedBuilds` on `install_bundle`, after the user approves those scripts in the conversation. The service validates pending names; it does not verify conversation approval. Approval persists by the recorded key in this profile, which pins a git-hosted package to its commit, permits commands with the host user's permissions, and survives another installation failure. Only currently undecided names can be approved; existing denials and wildcard rules cannot be overridden through this action. Approval rejects YAML anchors or aliases inside `allowBuilds`. Retry preserves the original activation choice.
|
||||
|
||||
<a id="version-compatibility-and-exemptions"></a>
|
||||
### Version compatibility and exemptions
|
||||
@@ -85,6 +87,7 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
|
||||
| `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. |
|
||||
| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. |
|
||||
| `githubConnectionTimeoutMs` | `5000` | Deadline for the GitHub repository check before installation, in milliseconds. |
|
||||
| `gitTransportTimeoutMs` | `5000` | Deadline for each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. |
|
||||
| `registry` | pnpm's own | The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. |
|
||||
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. |
|
||||
| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
|
||||
@@ -158,9 +161,11 @@ Tool results append to the transcript. Enabling or disabling other tools can cha
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Web approves the entire displayed pending group; it has no per-package selection.
|
||||
- An approved git key stays in `pnpm-workspace.yaml` after the plugin moves to a newer commit; nothing removes stale approvals.
|
||||
- pnpm verifies every lockfile tarball URL against its default registry when a command changes the lockfile. After a package was installed from another registry, installing a different package with the default registry, or removing that package after a restart, can fail with `ERR_PNPM_TARBALL_URL_MISMATCH`.
|
||||
- An update saves the target as an exact version, and a reinstallation after a failed update saves the previous version exactly, even when the profile had saved a range. With HMR the reinstalled bundle joins the end of the selection, which can change which bundle's configuration takes precedence.
|
||||
- An update runs its removal and its installation as two profile operations; another operation can run between them.
|
||||
- The Git transport probe keeps the parent console on Windows, so an SSH host-key or passphrase prompt can appear in the launching terminal until `gitTransportTimeoutMs`. On POSIX a host missing from `known_hosts` fails the SSH probe, so installation stays on HTTPS until the host key is accepted once, for example with `ssh -T git@<host>`. A credential-helper dialog or ssh-agent confirmation, such as a Touch ID or `ssh-add -c` dialog, that takes longer than the deadline counts as a failed probe, and an HTTPS probe that times out lets a reachable SSH form be saved to the profile. A private repository waits up to twice `gitTransportTimeoutMs` before pnpm starts. The SSH form always uses the `git` user and SSH's default port; other SSH endpoints need a `url.<base>.insteadOf` rule. When both probes fail, the diagnostic is pnpm's HTTPS failure; the SSH probe's reason is not reported.
|
||||
- A newer version whose dependencies need new build-script approval fails to update and is reinstalled at the previous version; approve its scripts through an ordinary installation after removing the package.
|
||||
- Package replacements require restarting the process without HMR. A package relinked outside Plugin Manager while it is still installed waits for a restart.
|
||||
- Startup-only profiles cannot remove packages used to start the current process; stop it and use `dsh plugin`.
|
||||
|
||||
@@ -57,6 +57,8 @@ kind: "package-reference"
|
||||
|
||||
`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。
|
||||
|
||||
HTTP(S) Git URL 指向 pnpm 没有 HTTPS 到 SSH 回退的主机(即 `hosted-git-info` 不认识的任何主机,例如自托管 GitLab)时,在启动 pnpm 前通过 `git ls-remote` 探测。探测使用安装器的 Git 配置与凭据助手,并禁用 Git 的终端与 askpass 提示;在 POSIX 上探测也没有控制终端,因此 SSH 无法提示。HTTP(S) 无法读取仓库而 `ssh://git@<host>/<path>` 可以读取时,pnpm 以该 SSH URL 加上原片段安装,profile 也记录该 URL。否则按原 spec 安装。每次探测受 `gitTransportTimeoutMs` 限制,默认为 5000 毫秒;取消安装或销毁管理器会停止探测。带凭据或查询串的 URL 跳过探测。
|
||||
|
||||
注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。
|
||||
|
||||
可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。
|
||||
@@ -65,7 +67,7 @@ kind: "package-reference"
|
||||
|
||||
`waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。
|
||||
|
||||
pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的包名,包括先前尝试留下的,以及本次运行报告为已忽略的。pnpm 只在能够交互提示时才把这些条目写入 `pnpm-workspace.yaml`,因此由管理器自行记录本次运行报告的包名;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按包名保存在当前 profile,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。
|
||||
pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 profile 中所有待决定的 `allowBuilds` 键,包括先前尝试留下的键、本次运行报告为已忽略的包名,以及 pnpm 为其拒绝准备的 git 托管包建议的键。git URL 携带凭据(密码,或 HTTP(S) 上的用户名)的键不会被记录,因此该安装只提供手动批准。pnpm 只在能够交互提示时才把这些条目写入 `pnpm-workspace.yaml`,因此由管理器自行记录本次运行报告的包名;失败的运行会恢复 `package.json` 与 `pnpm-lock.yaml`,但有意不恢复 `pnpm-workspace.yaml`。Web 插件页提供**允许这些脚本并重试**;工具可以在用户于对话中批准这些脚本后,通过 `install_bundle` 的 `approvedBuilds` 代为授权。服务只校验待决定的名字,不核实对话中的批准。授权按记录的键保存在当前 profile,该键会把 git 托管包固定到其提交,允许以宿主用户的权限执行命令,并在再次安装失败后保留。只能批准当前未决定的名字;已有的拒绝与通配规则不能通过此操作覆盖。`allowBuilds` 里出现 YAML 锚点或别名时拒绝授权。重试保留原来的启用选择。
|
||||
|
||||
<a id="version-compatibility-and-exemptions"></a>
|
||||
### 版本兼容性与豁免
|
||||
@@ -85,6 +87,7 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
|
||||
| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 |
|
||||
| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
|
||||
| `githubConnectionTimeoutMs` | `5000` | 安装前 GitHub 仓库连接检查的时限,单位毫秒。 |
|
||||
| `gitTransportTimeoutMs` | `5000` | 安装前对自托管 Git 仓库每次 HTTP(S) 与 SSH 访问探测的时限,单位毫秒。 |
|
||||
| `registry` | pnpm 自身配置 | 查询与安装首先询问的注册表,http(s) URL;缺省为 pnpm 自身配置指定的那个。 |
|
||||
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 |
|
||||
| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
|
||||
@@ -158,9 +161,11 @@ service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Web 一次批准显示出来的整组待决定包,没有逐包选择。
|
||||
- 插件移到更新的提交后,已批准的 git 键仍留在 `pnpm-workspace.yaml` 中;没有任何机制移除过期的批准。
|
||||
- 命令改动锁文件时,pnpm 按默认注册表校验锁文件中的每个 tarball URL。某个包从其他注册表安装后,使用默认注册表安装另一个包,或在重启后删除该包,可能以 `ERR_PNPM_TARBALL_URL_MISMATCH` 失败。
|
||||
- 更新把目标保存为确切版本,失败后的重新安装也把原版本保存为确切版本,即使 profile 原先保存的是范围。有 HMR 时,重新安装的组合包排到选中列表末尾,这可能改变哪个组合包的配置优先。
|
||||
- 更新把移除与安装作为两次 profile 操作运行,其他操作可能在两者之间运行。
|
||||
- 在 Windows 上,Git 传输探测沿用父进程的控制台,SSH 的主机密钥或密码提示可能出现在启动终端中,直到 `gitTransportTimeoutMs` 到期。在 POSIX 上,`known_hosts` 中没有的主机会让 SSH 探测失败,因此在接受一次主机密钥(例如执行 `ssh -T git@<host>`)之前一直走 HTTPS。凭据助手对话框或 ssh-agent 确认(例如 Touch ID 或 `ssh-add -c` 对话框)超过时限时算作探测失败;HTTPS 探测超时时,可达的 SSH 形式会写入 profile。私有仓库在 pnpm 启动前最多等待两倍 `gitTransportTimeoutMs`。SSH 形式固定使用 `git` 用户和 SSH 默认端口,其他 SSH 端点需要 `url.<base>.insteadOf` 规则。两种探测都失败时,诊断是 pnpm 的 HTTPS 失败,不报告 SSH 探测失败的原因。
|
||||
- 依赖需要新的构建脚本批准的新版本会更新失败,并重新安装为原版本;移除该包后通过普通安装批准其脚本。
|
||||
- 没有 HMR 时,替换包需要重启进程。在 Plugin Manager 之外重新链接仍处于安装状态的包,需要等待重启。
|
||||
- 仅启动时加载的 profile 不能删除当前进程启动时使用的包;停止进程后使用 `dsh plugin`。
|
||||
|
||||
@@ -70,7 +70,8 @@
|
||||
"zod": "4.4.3",
|
||||
"@deepseek-ai/dsh-util-values": "workspace:*",
|
||||
"@deepseek-ai/dsh-plugin-detail": "workspace:*",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:*"
|
||||
"@deepseek-ai/dsh-timeout": "workspace:*",
|
||||
"hosted-git-info": "^9.0.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~",
|
||||
@@ -115,6 +116,7 @@
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:*",
|
||||
"@deepseek-ai/dsh-sandbox": "workspace:*",
|
||||
"@deepseek-ai/dsh-user-approval": "workspace:*",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*"
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:*",
|
||||
"@types/hosted-git-info": "^3.0.5"
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,20 @@ const UNDECIDED_BUILD = 'set this to true or false'
|
||||
/** Where a non-interactive run names the build scripts it skipped. */
|
||||
const IGNORED_BUILDS = /\bIgnored build scripts: (.+)/
|
||||
|
||||
/** The `allowBuilds` key pnpm suggests when it refuses to prepare a git-hosted package; a subdirectory key can contain spaces. */
|
||||
const GIT_DEP_PREPARE = /ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED[\s\S]*?^allowBuilds:\r?\n[ \t]+(.+?): true$/m
|
||||
|
||||
/**
|
||||
* Whether an `allowBuilds` key names a git URL that carries a credential: a password, or any user name over HTTP(S).
|
||||
* Such a key is not recorded, so the credential stays out of the profile settings and the pending list.
|
||||
*/
|
||||
function carriesCredential(key: string): boolean {
|
||||
const at = key.indexOf('@git+')
|
||||
if (at === -1) return false
|
||||
const url = URL.parse(key.slice(at + '@git+'.length).replace(/#.*$/s, ''))
|
||||
return url !== null && (url.password !== '' || (url.username !== '' && /^https?:$/.test(url.protocol)))
|
||||
}
|
||||
|
||||
async function readPolicy(dir: string) {
|
||||
let text: string
|
||||
try { text = await readFile(join(dir, 'pnpm-workspace.yaml'), 'utf8') }
|
||||
@@ -37,19 +51,19 @@ async function readPolicy(dir: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Record the build scripts a failed run reported as ignored, then list every name awaiting a decision.
|
||||
* Record the build scripts a failed run reported as blocked, then list every `allowBuilds` key awaiting a decision.
|
||||
*
|
||||
* pnpm writes those policy entries itself only when it can prompt, and the
|
||||
* manager always runs it non-interactively, so the run's own report is the
|
||||
* record that survives.
|
||||
* @param dir Current profile directory.
|
||||
* @param output Captured output of the failed run.
|
||||
* @returns Exact package names awaiting a build decision; wildcard rules are excluded.
|
||||
* @returns Exact `allowBuilds` keys awaiting a build decision; wildcard rules are excluded.
|
||||
*/
|
||||
export async function recordPendingBuilds(dir: string, output: string): Promise<string[]> {
|
||||
const { document, pending, keys } = await readPolicy(dir)
|
||||
const known = new Set(keys)
|
||||
const added = ignoredBuildNames(output).filter(name => !known.has(name))
|
||||
const added = blockedBuildNames(output).filter(name => !known.has(name))
|
||||
if (added.length === 0) return pending
|
||||
for (const name of added) document.setIn(['allowBuilds', name], UNDECIDED_BUILD)
|
||||
await writeFileAtomic(join(dir, 'pnpm-workspace.yaml'), String(document), { mode: 0o600 })
|
||||
@@ -57,14 +71,15 @@ export async function recordPendingBuilds(dir: string, output: string): Promise<
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the package names a pnpm run reported as ignored build scripts.
|
||||
* Read the `allowBuilds` keys a pnpm run reported as ignored build scripts or as a git-hosted package it refused to prepare.
|
||||
* @param output Captured output of the run.
|
||||
* @returns Exact names in report order; wildcard patterns are excluded.
|
||||
* @returns Exact keys in report order; wildcard patterns and git keys carrying a credential are excluded.
|
||||
*/
|
||||
export function ignoredBuildNames(output: string): string[] {
|
||||
const reported = IGNORED_BUILDS.exec(output)?.[1]
|
||||
if (reported === undefined) return []
|
||||
return reported.split(',').map(name => name.trim()).filter(name => name !== '' && !/[*?]/.test(name))
|
||||
export function blockedBuildNames(output: string): string[] {
|
||||
const ignored = IGNORED_BUILDS.exec(output)?.[1]?.split(',') ?? []
|
||||
const git = GIT_DEP_PREPARE.exec(output)?.[1]
|
||||
return [...ignored, ...git === undefined ? [] : [git]]
|
||||
.map(name => name.trim()).filter(name => name !== '' && !/[*?]/.test(name) && !carriesCredential(name))
|
||||
}
|
||||
|
||||
/** Persist approval without running scripts; the caller holds the profile manifest lock.
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/** The Git transport an installation of a self-hosted repository uses, chosen by probing access before pnpm starts. */
|
||||
import { execa } from 'execa'
|
||||
import hostedGitInfo from 'hosted-git-info'
|
||||
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
||||
import type { ParsedInstallSpec } from './install-spec.ts'
|
||||
|
||||
/** Deadline and cancellation for each read-only access probe. */
|
||||
export interface GitTransportOptions {
|
||||
timeoutMs: number
|
||||
signal: AbortSignal
|
||||
/** The application-owned package manager's environment, also used by its Git children. */
|
||||
env?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
/** An HTTP(S) Git URL with an optional `git+` prefix, which pnpm clones over that transport as written. */
|
||||
const HTTP_GIT_URL = /^(?:git\+)?https?:\/\//i
|
||||
|
||||
/**
|
||||
* The SSH form of an HTTP(S) Git spec on a host pnpm has no transport fallback for. pnpm tries HTTPS and SSH itself
|
||||
* for the hosts `hosted-git-info` knows, such as GitHub and GitLab.com, and clones any other URL over the transport
|
||||
* it names.
|
||||
* @param spec The parsed installation address.
|
||||
* @returns `git+ssh://git@<host>/<path>` on SSH's default port with the spec's fragment, or `undefined` for a spec that
|
||||
* is not an HTTP(S) Git URL on such a host, or whose URL carries credentials or a query.
|
||||
*/
|
||||
export function sshGitSpec(spec: ParsedInstallSpec): string | undefined {
|
||||
if (spec.kind !== 'git' || !HTTP_GIT_URL.test(spec.spec) || hostedGitInfo.fromUrl(spec.spec) !== undefined) return undefined
|
||||
const url = new URL(spec.spec.replace(/^git\+/i, ''))
|
||||
if (url.username !== '' || url.password !== '' || url.search !== '') return undefined
|
||||
// The fragment is copied as written: WHATWG serialization would percent-encode some of its characters.
|
||||
const hash = /#.*$/s.exec(spec.spec)?.[0] ?? ''
|
||||
return `git+ssh://git@${url.hostname}${url.pathname}${hash}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether Git can read a repository with the installer's Git configuration and credential helpers. Git's
|
||||
* terminal and askpass prompts are disabled, `core.askPass` included because Windows drops the empty `GIT_ASKPASS`,
|
||||
* and on POSIX the probe has no controlling terminal, so SSH cannot ask about host keys or passphrases either.
|
||||
* @param repository The repository URL Git reads.
|
||||
* @param dir The profile directory where installation runs.
|
||||
* @param options The probe deadline, cancellation and environment.
|
||||
* @returns Whether `git ls-remote` listed the repository's HEAD.
|
||||
*/
|
||||
async function readable(repository: string, dir: string, options: GitTransportOptions): Promise<boolean> {
|
||||
const result = await execa('git', ['-c', 'core.askPass=', 'ls-remote', '--exit-code', '--', repository, 'HEAD'], {
|
||||
cwd: dir,
|
||||
env: {
|
||||
...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never',
|
||||
GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never',
|
||||
},
|
||||
extendEnv: false, stdin: 'ignore', stdout: 'ignore', stderr: 'ignore', reject: false,
|
||||
timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL',
|
||||
})
|
||||
return !result.failed
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose how pnpm clones an HTTP(S) Git repository that {@link sshGitSpec} has an SSH form for: probe the spec's URL,
|
||||
* and when HTTP(S) cannot read it, probe the SSH form.
|
||||
* @param spec The parsed installation address.
|
||||
* @param dir The profile directory where installation runs.
|
||||
* @param options The deadline of each probe, cancellation and the installer's environment.
|
||||
* @returns The SSH form when only SSH can read the repository; otherwise the spec unchanged.
|
||||
*/
|
||||
export async function chooseGitTransport(spec: ParsedInstallSpec, dir: string, options: GitTransportOptions): Promise<string> {
|
||||
const ssh = sshGitSpec(spec)
|
||||
if (ssh === undefined) return spec.spec
|
||||
const strip = (value: string): string => value.replace(/^git\+/i, '').replace(/#.*$/s, '')
|
||||
if (await readable(strip(spec.spec), dir, options)) return spec.spec
|
||||
if (options.signal.aborted || !await readable(strip(ssh), dir, options)) return spec.spec
|
||||
return ssh
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import { flatten, writePluginEnabled } from './patch.ts'
|
||||
import { incompatiblePlugin, ManagementFailure } from './failure.ts'
|
||||
import { approveBuilds, recordPendingBuilds } from './build-approval.ts'
|
||||
import { checkGithubConnection } from './github-connection.ts'
|
||||
import { chooseGitTransport, sshGitSpec } from './git-transport.ts'
|
||||
import { newerVersions, VERSION_FIELDS, VERSION_LIST_FIELDS, versionFacts } from './versions.ts'
|
||||
import type {
|
||||
BundleDetail, BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ListVersionsOptions, ManagementError,
|
||||
@@ -59,6 +60,8 @@ export interface Config {
|
||||
inspectTimeoutMs?: number
|
||||
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
|
||||
githubConnectionTimeoutMs?: number
|
||||
/** Maximum duration of each HTTP(S) and SSH access probe of a self-hosted Git repository before installation, in milliseconds. */
|
||||
gitTransportTimeoutMs?: number
|
||||
/** Maximum time one captured package run may print nothing before the manager terminates it, in milliseconds. */
|
||||
idleTimeoutMs?: number
|
||||
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
|
||||
@@ -249,7 +252,7 @@ function bundleSelection(manifest: ProfileManifest): readonly string[] {
|
||||
|
||||
/**
|
||||
* The package an installation spec names before pnpm runs: a registry name, a local directory's manifest name, or the
|
||||
* dependency saved with this git or tarball spec, compared as the plugin list shows saved specs.
|
||||
* dependency saved with this git or tarball spec or the SSH form of its Git URL, compared as the plugin list shows saved specs.
|
||||
* @param spec The installation spec.
|
||||
* @param dependencies The profile's saved dependencies.
|
||||
* @param profileDir The profile directory saved local paths are relative to.
|
||||
@@ -259,7 +262,8 @@ function specPackageName(spec: string, dependencies: Readonly<Record<string, str
|
||||
const parsed = parsedForRegistry(spec)
|
||||
if (parsed.kind === 'registry') return parsed.name
|
||||
if (parsed.kind === 'path') return packageName(parsed.path)
|
||||
const specs = parsed.kind === 'tarball' && parsed.path !== undefined ? [spec, `file:${parsed.path}`] : [spec]
|
||||
const alternative = parsed.kind === 'tarball' && parsed.path !== undefined ? `file:${parsed.path}` : sshGitSpec(parsed)
|
||||
const specs = alternative === undefined ? [spec] : [spec, alternative]
|
||||
return Object.entries(dependencies).find(([dependency, saved]) => specs.includes(dependencySpec(dependency, saved, profileDir)))?.[0]
|
||||
}
|
||||
|
||||
@@ -300,6 +304,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
lockWaitMs: z.number().step(1).min(0).default(120000),
|
||||
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
|
||||
githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000),
|
||||
gitTransportTimeoutMs: z.number().step(1).min(1000).default(5000),
|
||||
idleTimeoutMs: z.number().step(1).min(1000).default(600000),
|
||||
registry: z.string().pattern(REGISTRY_URL),
|
||||
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
|
||||
@@ -324,6 +329,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
private readonly lockWaitMs: number
|
||||
private readonly inspectTimeoutMs: number
|
||||
private readonly githubConnectionTimeoutMs: number
|
||||
private readonly gitTransportTimeoutMs: number
|
||||
private readonly idleTimeoutMs: number
|
||||
private readonly pnpmCommand: string
|
||||
private readonly versionListLimit: number
|
||||
@@ -345,6 +351,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
this.lockWaitMs = (config as Required<Config>).lockWaitMs
|
||||
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
|
||||
this.githubConnectionTimeoutMs = (config as Required<Config>).githubConnectionTimeoutMs
|
||||
this.gitTransportTimeoutMs = (config as Required<Config>).gitTransportTimeoutMs
|
||||
this.idleTimeoutMs = (config as Required<Config>).idleTimeoutMs
|
||||
this.pnpmCommand = (config as Required<Config>).pnpmCommand
|
||||
this.versionListLimit = (config as Required<Config>).versionListLimit
|
||||
@@ -827,7 +834,9 @@ export class PluginManager extends TypertRemoteService {
|
||||
/**
|
||||
* Install a package using the same pnpm implementation as dsh plugin. GitHub
|
||||
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
|
||||
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
|
||||
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. An
|
||||
* HTTP(S) Git URL on another host is installed over SSH instead when access probes bounded by gitTransportTimeoutMs
|
||||
* find that only SSH can read it. A run
|
||||
* that fails, is cancelled, or adds a package without a bundle patch restores
|
||||
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
|
||||
* @param spec One package spec, including local paths relative to the invocation directory.
|
||||
@@ -862,10 +871,11 @@ export class PluginManager extends TypertRemoteService {
|
||||
let version: string | undefined
|
||||
try {
|
||||
result.registries = []
|
||||
const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
|
||||
timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes,
|
||||
signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
|
||||
...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env },
|
||||
const parsed = parsedForRegistry(spec)
|
||||
const signal = AbortSignal.any([this.abort.signal, control.abort.signal])
|
||||
const env = this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env }
|
||||
const connection = checkGithubConnection(parsed, this.profile.dir, {
|
||||
timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes, signal, ...env,
|
||||
})
|
||||
this.packageOperations.add(connection)
|
||||
let connectionFailure: PackageResult | undefined
|
||||
@@ -877,6 +887,12 @@ export class PluginManager extends TypertRemoteService {
|
||||
result.failedAt = 'spec-host'
|
||||
throw new Error(connectionFailure.output)
|
||||
}
|
||||
const transport = chooseGitTransport(parsed, this.profile.dir, { timeoutMs: this.gitTransportTimeoutMs, signal, ...env })
|
||||
this.packageOperations.add(transport)
|
||||
let installSpec: string
|
||||
try { installSpec = await transport }
|
||||
finally { this.packageOperations.delete(transport) }
|
||||
if (stopped()) throw new InstallCancelledError()
|
||||
// The last run is the result's; the registries asked stay listed whatever the outcome.
|
||||
const plan = registryPlan(options?.registry, await this.registries())
|
||||
let run: PackageResult | undefined
|
||||
@@ -888,7 +904,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
result.registries.push(registry)
|
||||
used = registry
|
||||
announce('installing', { registry, index: index + 1, total: plan.length })
|
||||
run = await this.runPnpm(['add', spec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId)
|
||||
run = await this.runPnpm(['add', installSpec, ...options?.saveExact === true ? ['--save-exact'] : [], ...registryArguments(registry)], control.abort.signal, requestId)
|
||||
result.packageResult = run
|
||||
if (stopped()) throw new InstallCancelledError()
|
||||
// A compatibility refusal is the package's own answer, so no other registry is asked.
|
||||
@@ -901,7 +917,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
delete result.failedAt
|
||||
// A run this manager terminated got no answer from the registry at all, so no registry explains it.
|
||||
if (run.timedOut === true) break
|
||||
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(spec))
|
||||
const failedAt = attributeFailure(run.kind, run.output, parsedForRegistry(installSpec))
|
||||
if (failedAt !== 'other') result.failedAt = failedAt
|
||||
if (failedAt !== 'registry' || index === plan.length - 1) break
|
||||
}
|
||||
@@ -911,7 +927,7 @@ export class PluginManager extends TypertRemoteService {
|
||||
const succeeded = run.exitCode === 0 && run.timedOut !== true
|
||||
if (succeeded) delete result.failedAt
|
||||
if (!succeeded) {
|
||||
// pnpm-workspace.yaml is not restored, so the names it left undecided, or reported as ignored, are offered for approval.
|
||||
// pnpm-workspace.yaml is not restored, so the names it left undecided, or reported as blocked, are offered for approval.
|
||||
try { result.pendingBuilds = await recordPendingBuilds(this.profile.dir, run.output) }
|
||||
catch (error) {
|
||||
this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface InstallFailureFacts {
|
||||
|
||||
/** Patterns in the order they decide: a specific code before the generic network family. */
|
||||
const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [
|
||||
['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/],
|
||||
['build-blocked', /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts|ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED/],
|
||||
['not-found', /ERR_PNPM_FETCH_404|\bE404\b|404 Not Found|Not Found - GET/],
|
||||
['no-matching-version', /ERR_PNPM_NO_MATCHING_VERSION|\bETARGET\b|No matching version/],
|
||||
['disk-full', /\bENOSPC\b|no space left on device/i],
|
||||
|
||||
@@ -170,7 +170,7 @@ export interface ChangeResult {
|
||||
bundle?: string
|
||||
/** The installed bundle's manifest version, when declared; pnpm's `minimumReleaseAge` can make it older than the newest release. */
|
||||
version?: string
|
||||
/** Exact package names awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */
|
||||
/** Exact `allowBuilds` keys awaiting explicit script approval in the profile's pnpm settings, read after a failed run. */
|
||||
pendingBuilds?: string[]
|
||||
/** Package script permissions saved before this installation attempt. */
|
||||
approvedBuilds?: string[]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/** Pending script permissions survive cleanup and preserve unrelated workspace settings. */
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { expect, it, onTestFinished } from 'vitest'
|
||||
@@ -89,6 +89,40 @@ it('records the ignored build scripts a non-interactive run reported', async ()
|
||||
expect(parse(readFileSync(filename, 'utf8'))).toMatchObject({ allowBuilds: { '[email protected]': true } })
|
||||
})
|
||||
|
||||
function gitPrepareRefusal(key: string, eol = '\n') {
|
||||
return [
|
||||
'[ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED] Failed to prepare git-hosted package fetched from "https://example.com/sidechat.git": The git-hosted package "@scope/[email protected]" needs to execute build scripts but is not in the "allowBuilds" allowlist.',
|
||||
'',
|
||||
'Add the package to "allowBuilds" in your project\'s pnpm-workspace.yaml to allow it to run scripts. For example:',
|
||||
'allowBuilds:',
|
||||
` ${key}: true`,
|
||||
'',
|
||||
].join(eol)
|
||||
}
|
||||
|
||||
it.each([
|
||||
['LF', '@scope/sidechat@git+https://example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', '\n'],
|
||||
['CRLF', '@scope/sidechat@git+https://example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', '\r\n'],
|
||||
['a subdirectory with a space', '@scope/sidechat@git+file:///repo#244b67419a80844f5fb1fd7971109b9c19a20b76&path:plugin folder', '\n'],
|
||||
['an SSH user name', '@scope/sidechat@git+ssh://[email protected]/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76', '\n'],
|
||||
])('records the allowBuilds key pnpm suggests for a git-hosted package it refused to prepare (%s)', async (_label, key, eol) => {
|
||||
const { dir, filename } = fixture()
|
||||
expect(await recordPendingBuilds(dir, gitPrepareRefusal(key, eol))).toEqual([key])
|
||||
await approveBuilds(dir, [key])
|
||||
expect(parse(readFileSync(filename, 'utf8'))).toMatchObject({ allowBuilds: { [key]: true } })
|
||||
})
|
||||
|
||||
it.each([
|
||||
'@scope/sidechat@git+https://user:[email protected]/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
'@scope/sidechat@git+http://[email protected]/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
'@scope/sidechat@git+ssh://git:[email protected]/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
])('does not record a git key that carries a credential: %s', async (key) => {
|
||||
const { dir, filename } = fixture()
|
||||
expect(await recordPendingBuilds(dir, gitPrepareRefusal(key))).toEqual([])
|
||||
expect(await recordPendingBuilds(dir, `[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: ${key}\n`)).toEqual([])
|
||||
expect(existsSync(filename)).toBe(false)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no report', 'Progress: resolved 1, reused 1, downloaded 0, added 1, done\n'],
|
||||
['an empty report', '[ERR_PNPM_IGNORED_BUILDS] Ignored build scripts: \n'],
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/** Real Git access probes that choose the transport of a self-hosted repository installation. */
|
||||
import { once } from 'node:events'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import * as subprocess from 'execa'
|
||||
import { afterAll, beforeAll, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { chooseGitTransport } from '../src/git-transport.ts'
|
||||
import { parseInstallSpec } from '../src/install-spec.ts'
|
||||
import { isolateGitCommandLineConfig } from './git-environment.ts'
|
||||
|
||||
vi.mock('execa', async (original) => {
|
||||
const module = await original<typeof import('execa')>()
|
||||
return { ...module, execa: vi.fn(module.execa) }
|
||||
})
|
||||
|
||||
// These cases spawn real Git, so the host's command-line configuration group must not reach its children.
|
||||
let restoreGitCommandLineConfig: () => void
|
||||
beforeAll(() => { restoreGitCommandLineConfig = isolateGitCommandLineConfig() })
|
||||
afterAll(() => { restoreGitCommandLineConfig() })
|
||||
|
||||
const SPEC = `git+https://git.example/team/sub/plugin.git#${'a'.repeat(40)}&path:/packages/a`
|
||||
const SSH_SPEC = `git+ssh://[email protected]/team/sub/plugin.git#${'a'.repeat(40)}&path:/packages/a`
|
||||
|
||||
async function fixture() {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'dsh-git-transport-'))
|
||||
onTestFinished(() => rm(dir, { recursive: true, force: true }))
|
||||
const config = join(dir, 'git.config')
|
||||
await writeFile(config, '')
|
||||
const env = { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }
|
||||
const git = (...args: string[]) => subprocess.execa('git', args, { env })
|
||||
const repository = join(dir, 'plugin.git')
|
||||
await git('init', '--bare', repository)
|
||||
// ls-remote --exit-code needs a HEAD that names a commit.
|
||||
const work = join(dir, 'work')
|
||||
await git('init', work)
|
||||
await git('-C', work, '-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'commit.gpgsign=false', 'commit', '--allow-empty', '-m', 'init')
|
||||
await git('-C', work, 'push', repository, 'HEAD:refs/heads/main')
|
||||
await git('-C', repository, 'symbolic-ref', 'HEAD', 'refs/heads/main')
|
||||
const controller = new AbortController()
|
||||
const tasks: Promise<unknown>[] = []
|
||||
// Registered after the directory removal, so it runs first: no probe outlives the directory it runs in.
|
||||
onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) })
|
||||
const choose = (spec: string, timeoutMs = 5000) => {
|
||||
const task = chooseGitTransport(parseInstallSpec(spec), dir, { timeoutMs, signal: controller.signal, env })
|
||||
tasks.push(task)
|
||||
return task
|
||||
}
|
||||
return { dir, config, repository: pathToFileURL(repository).href, controller, choose }
|
||||
}
|
||||
|
||||
/** An HTTP Git endpoint that answers every request with `handler`. */
|
||||
async function server(handler: (request: IncomingMessage, response: ServerResponse) => void): Promise<string> {
|
||||
const http = createServer(handler)
|
||||
onTestFinished(async () => {
|
||||
http.closeAllConnections()
|
||||
await new Promise<void>((resolve, reject) => { http.close((error) => { if (error) reject(error); else resolve() }) })
|
||||
})
|
||||
http.listen(0, '127.0.0.1')
|
||||
await once(http, 'listening')
|
||||
const address = http.address()
|
||||
if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port')
|
||||
return `http://127.0.0.1:${String(address.port)}/`
|
||||
}
|
||||
|
||||
const unauthorized = (_request: IncomingMessage, response: ServerResponse): void => {
|
||||
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
|
||||
response.end()
|
||||
}
|
||||
|
||||
it('keeps the HTTPS spec when HTTPS can read the repository', async () => {
|
||||
const { config, repository, choose } = await fixture()
|
||||
await writeFile(config, `[url "${repository}"]\n insteadOf = https://git.example/team/sub/plugin.git\n insteadOf = ssh://[email protected]/team/sub/plugin.git\n`)
|
||||
expect(await choose(SPEC)).toBe(SPEC)
|
||||
})
|
||||
|
||||
it('installs over SSH when HTTPS asks for credentials that SSH does not need', async () => {
|
||||
const { config, repository, choose } = await fixture()
|
||||
const http = await server(unauthorized)
|
||||
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n insteadOf = http://git.example/\n[url "${repository}"]\n insteadOf = ssh://[email protected]/team/sub/plugin.git\n[http]\n proxy =\n`)
|
||||
expect(await choose(SPEC)).toBe(SSH_SPEC)
|
||||
// The fragment is copied as written, not as URL serialization would encode it.
|
||||
expect(await choose('git+https://git.example/team/sub/plugin.git#semver:<2 >1')).toBe('git+ssh://[email protected]/team/sub/plugin.git#semver:<2 >1')
|
||||
expect(await choose('git+http://git.example/team/sub/plugin.git')).toBe('git+ssh://[email protected]/team/sub/plugin.git')
|
||||
})
|
||||
|
||||
it('keeps the HTTPS spec when neither transport can read the repository', async () => {
|
||||
const { config, choose } = await fixture()
|
||||
const http = await server(unauthorized)
|
||||
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n[url "file:///nonexistent/plugin.git"]\n insteadOf = ssh://[email protected]/\n[http]\n proxy =\n`)
|
||||
expect(await choose(SPEC)).toBe(SPEC)
|
||||
})
|
||||
|
||||
it('lets configured credential helpers answer the HTTPS probe', async () => {
|
||||
const { dir, config, repository, choose } = await fixture()
|
||||
const expected = `Basic ${Buffer.from('person:token').toString('base64')}`
|
||||
const http = await server((request, response) => {
|
||||
if (request.headers.authorization === expected) {
|
||||
// Any reply Git can parse as a ref advertisement is enough; a dumb-HTTP info/refs listing is the smallest.
|
||||
response.writeHead(200, { 'Content-Type': 'text/plain' })
|
||||
response.end(`${'b'.repeat(40)}\tHEAD\n`)
|
||||
} else unauthorized(request, response)
|
||||
})
|
||||
const helper = join(dir, 'credential-helper.cjs')
|
||||
await writeFile(helper, 'process.stdout.write("username=person\\npassword=token\\n")')
|
||||
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n[url "${repository}"]\n insteadOf = ssh://[email protected]/team/sub/plugin.git\n[http]\n proxy =\n`)
|
||||
await subprocess.execa('git', ['config', '--file', config, 'credential.helper', `!"${process.execPath.replaceAll('\\', '/')}" "${helper.replaceAll('\\', '/')}"`])
|
||||
expect(await choose(SPEC)).toBe(SPEC)
|
||||
})
|
||||
|
||||
it.each([
|
||||
'@acme/plugin',
|
||||
'github:acme/plugin',
|
||||
'https://gitlab.com/acme/plugin.git',
|
||||
'git+https://github.com/acme/plugin.git#main',
|
||||
'git+ssh://[email protected]/team/plugin.git',
|
||||
'[email protected]:team/plugin.git',
|
||||
'git+https://person:[email protected]/team/plugin.git',
|
||||
'git+https://git.example/team/plugin.git?ref=x',
|
||||
])('leaves %s to pnpm without probing', async (spec) => {
|
||||
const { choose } = await fixture()
|
||||
const run = vi.mocked(subprocess.execa)
|
||||
run.mockClear()
|
||||
expect(await choose(spec)).toBe(spec)
|
||||
expect(run).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ends an in-flight HTTPS probe on cancellation and keeps the spec', async () => {
|
||||
const { config, controller, choose } = await fixture()
|
||||
const requested = Promise.withResolvers<undefined>()
|
||||
// Never answers, so only cancellation ends the probe.
|
||||
const http = await server(() => { requested.resolve(undefined) })
|
||||
await writeFile(config, `[url "${http}"]\n insteadOf = https://git.example/\n[http]\n proxy =\n`)
|
||||
const run = vi.mocked(subprocess.execa)
|
||||
run.mockClear()
|
||||
const choosing = choose(SPEC, 60000)
|
||||
await requested.promise
|
||||
controller.abort()
|
||||
expect(await choosing).toBe(SPEC)
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
expect(run.mock.calls[0]?.[1]).toEqual(['-c', 'core.askPass=', 'ls-remote', '--exit-code', '--', 'https://git.example/team/sub/plugin.git', 'HEAD'])
|
||||
})
|
||||
|
||||
it('does not probe SSH after cancellation', async () => {
|
||||
const { controller, choose } = await fixture()
|
||||
const run = vi.mocked(subprocess.execa)
|
||||
run.mockClear()
|
||||
controller.abort()
|
||||
expect(await choose(SPEC)).toBe(SPEC)
|
||||
expect(run).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
@@ -64,6 +64,7 @@ describe('classifyInstallFailure', () => {
|
||||
expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout')
|
||||
expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing')
|
||||
expect(classifyInstallFailure({ log: 'ERR_PNPM_IGNORED_BUILDS Ignored build scripts: node-pty' })).toBe('build-blocked')
|
||||
expect(classifyInstallFailure({ log: '[ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED] Failed to prepare git-hosted package' })).toBe('build-blocked')
|
||||
expect(classifyInstallFailure({ log: 'npm error code E404\nnpm error 404 Not Found - GET https://registry/x' })).toBe('not-found')
|
||||
expect(classifyInstallFailure({ log: 'ERR_PNPM_NO_MATCHING_VERSION No matching version found for x@9' })).toBe('no-matching-version')
|
||||
expect(classifyInstallFailure({ log: 'npm error code ETARGET' })).toBe('no-matching-version')
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { Group } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import * as operations from '../src/operations.ts'
|
||||
import * as githubConnection from '../src/github-connection.ts'
|
||||
import * as gitTransport from '../src/git-transport.ts'
|
||||
import { parse, parseDocument } from 'yaml'
|
||||
import { isolateGitCommandLineConfig } from './git-environment.ts'
|
||||
|
||||
@@ -138,6 +139,47 @@ it('cancels an active GitHub check before starting pnpm', async () => {
|
||||
expect(pnpm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('installs the spec the Git transport probe chooses with the profile Git environment and deadline', async () => {
|
||||
const env = { GIT_CONFIG_GLOBAL: '/application/git.config' }
|
||||
const { manager, dir } = await fixture('live', false, undefined, { gitTransportTimeoutMs: 7000 }, { command: 'pnpm', args: [], env })
|
||||
const spec = 'git+https://git.example/team/sub/plugin.git#main'
|
||||
const transport = vi.spyOn(gitTransport, 'chooseGitTransport').mockResolvedValue('git+ssh://[email protected]/team/sub/plugin.git#main')
|
||||
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({ exitCode: 1, output: 'stop', truncated: false, logPath: 'pnpm.log', kind: 'unknown' })
|
||||
onTestFinished(() => { transport.mockRestore(); pnpm.mockRestore() })
|
||||
expect((await manager.installBundle(spec)).application).toBe('failed')
|
||||
expect(transport).toHaveBeenCalledWith(expect.objectContaining({ kind: 'git', spec }), dir, expect.objectContaining({ timeoutMs: 7000, env }))
|
||||
expect(pnpm.mock.calls[0]?.[1]).toEqual(['add', 'git+ssh://[email protected]/team/sub/plugin.git#main'])
|
||||
})
|
||||
|
||||
it('attributes a failure to the host of the SSH spec the probe chose, which carries no HTTPS port', async () => {
|
||||
const { manager } = await fixture()
|
||||
const transport = vi.spyOn(gitTransport, 'chooseGitTransport').mockResolvedValue('git+ssh://[email protected]/team/plugin.git#main')
|
||||
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue({
|
||||
exitCode: 1, output: 'ssh: Could not resolve hostname git.example: nodename nor servname provided', truncated: false, logPath: 'pnpm.log',
|
||||
})
|
||||
onTestFinished(() => { transport.mockRestore(); pnpm.mockRestore() })
|
||||
expect(await manager.installBundle('git+https://git.example:8443/team/plugin.git#main')).toMatchObject({ application: 'failed', failedAt: 'spec-host' })
|
||||
expect(pnpm).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cancels an active Git transport probe before starting pnpm', async () => {
|
||||
const { manager } = await fixture()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const transport = vi.spyOn(gitTransport, 'chooseGitTransport').mockImplementation(async (spec, _dir, options) => {
|
||||
entered.resolve(undefined)
|
||||
await new Promise<void>((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) })
|
||||
return spec.spec
|
||||
})
|
||||
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
|
||||
onTestFinished(() => { transport.mockRestore(); pnpm.mockRestore() })
|
||||
const requestId = '5f0c1f8e-3f5e-4a59-9a43-1d0c5c3f7a21' as PluginInstallRequestId
|
||||
const installing = manager.installBundle('git+https://git.example/team/plugin.git', { requestId })
|
||||
await entered.promise
|
||||
expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
|
||||
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
|
||||
expect(pnpm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('waits for the GitHub check before installing and activating the bundle', async () => {
|
||||
const { manager, dir, bundle, connection } = await fixture()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
@@ -624,6 +666,38 @@ it('runs a real pnpm dependency script only after approval and retry', async ()
|
||||
expect(readFileSync(built, 'utf8')).toBe('built')
|
||||
})
|
||||
|
||||
it('offers approval for a real git-hosted package pnpm refused to prepare', async () => {
|
||||
// Both the fixture's Git and the manager's pnpm child read only this empty configuration.
|
||||
const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' }
|
||||
const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url))
|
||||
const { manager, dir, profile } = await fixture('startup', false, undefined, {}, { command: process.execPath, args: ['--expose-internals', pnpm], env })
|
||||
const repository = join(profile.cwd, 'repository')
|
||||
mkdirSync(repository)
|
||||
env.GIT_CONFIG_GLOBAL = join(profile.cwd, 'git.config')
|
||||
writeFileSync(env.GIT_CONFIG_GLOBAL, '')
|
||||
const git = (args: string[]) => execa('git', args, { cwd: repository, env })
|
||||
await git(['init', '--initial-branch=main'])
|
||||
writeFileSync(join(repository, 'package.json'), JSON.stringify({ name: '@test/git-prepared', version: '1.0.0',
|
||||
scripts: { prepare: 'node build.cjs' }, dsh: { bundle: { patch: './cordis.patch.yml' } } }))
|
||||
writeFileSync(join(repository, 'build.cjs'), 'require("node:fs").writeFileSync("built.txt", "built")\n')
|
||||
writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n')
|
||||
await git(['add', '.'])
|
||||
await git(['-c', 'user.name=Fixture', '-c', '[email protected]', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle'])
|
||||
writeFileSync(join(dir, 'package.json'), '{"name":"git-approval-fixture","private":true}\n')
|
||||
const policy = parseDocument(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8'))
|
||||
policy.set('storeDir', join(profile.cwd, 'store'))
|
||||
writeFileSync(join(dir, 'pnpm-workspace.yaml'), String(policy))
|
||||
const spec = `git+${pathToFileURL(repository).href}`
|
||||
const blocked = await manager.installBundle(spec, { enabled: false })
|
||||
expect(blocked, JSON.stringify(blocked)).toMatchObject({ application: 'failed', packageResult: { kind: 'build-blocked' } })
|
||||
expect(blocked.pendingBuilds).toEqual([expect.stringMatching(/^@test\/git-prepared@git\+file:.*#[0-9a-f]{40}$/)])
|
||||
const built = join(dir, 'node_modules', '@test', 'git-prepared', 'built.txt')
|
||||
expect(existsSync(built)).toBe(false)
|
||||
const allowed = await manager.installBundle(spec, { enabled: false, approvedBuilds: blocked.pendingBuilds! })
|
||||
expect(allowed, JSON.stringify(allowed)).toMatchObject({ application: 'restart-required', packageResult: { exitCode: 0 } })
|
||||
expect(readFileSync(built, 'utf8')).toBe('built')
|
||||
})
|
||||
|
||||
it.each(['[', 'allowBuilds: false\n'])('preserves pnpm diagnostics when pending approvals cannot be read: %s', async (policy) => {
|
||||
const { manager, dir } = await fixture()
|
||||
writeFileSync(join(dir, 'pnpm-workspace.yaml'), policy)
|
||||
@@ -833,6 +907,10 @@ it('refuses to install over an installed package, by name, version or local dire
|
||||
expect(await manager.installBundle(join(dir, '..', 'extra-1.0.0.tgz'), { approvedBuilds: ['native-provider'] }))
|
||||
.toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } })
|
||||
expect(existsSync(workspace) ? readFileSync(workspace, 'utf8') : undefined).toBe(settings)
|
||||
// A self-hosted HTTPS Git spec matches the SSH form an earlier installation saved.
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { extra: 'git+ssh://[email protected]/team/sub/extra.git#main' } }))
|
||||
expect(await manager.installBundle('git+https://git.example/team/sub/extra.git#main'))
|
||||
.toMatchObject({ application: 'failed', changed: false, error: { code: 'already-installed' } })
|
||||
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
|
||||
expect(install).not.toHaveBeenCalled()
|
||||
expect(readProfileManifest('test', dir).dependencies).toEqual({ extra: '1.0.0' })
|
||||
|
||||
@@ -35,7 +35,7 @@ function fakeHttpServer(
|
||||
return {
|
||||
host,
|
||||
protocol,
|
||||
register(route) {
|
||||
register(route: WebRoute) {
|
||||
if (routes.some(candidate => candidate.kind === route.kind && candidate.path === route.path)) {
|
||||
throw new Error(`duplicate route ${route.path}`)
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function fakeClientModuleHost(rows: Map<string, string>, options: FakeHostOption
|
||||
// carries private state a literal cannot (and need not) reproduce.
|
||||
function fakeHttpServer(routes: WebRoute[]): WebServer {
|
||||
const fake: Pick<WebServer, 'register' | 'tapIndex' | 'port'> = {
|
||||
register(route) {
|
||||
register(route: WebRoute) {
|
||||
routes.push(route)
|
||||
return () => { routes.splice(routes.indexOf(route), 1) }
|
||||
},
|
||||
|
||||
@@ -28,11 +28,9 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { createReadStream, existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, isAbsolute, join } from 'node:path'
|
||||
import { Readable } from 'node:stream'
|
||||
import { pipeline } from 'node:stream/promises'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { isDeepStrictEqual } from 'node:util'
|
||||
import { Service } from '@deepseek-ai/cordis'
|
||||
@@ -255,6 +253,19 @@ function transforStreamBody(stream: Readable): ReadableStream<Uint8Array> {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose a held bundle buffer as a Response body without copying it: the
|
||||
* single chunk is the buffer itself, which readers must not modify.
|
||||
*/
|
||||
function heldBody(buffer: Buffer): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(buffer)
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Combo query addressing one ordered plugin-file list. */
|
||||
function comboSearch(ids: readonly string[], rev: string, sourceMap = false): string {
|
||||
const resources = ids.map(id => `${id}/client.js${sourceMap ? '.map' : ''}`).join(',')
|
||||
@@ -689,7 +700,7 @@ export class ClientModuleRegistry extends Service {
|
||||
|
||||
const registerWebCarrier = (webCtx: Context): void => {
|
||||
webCtx.effect(
|
||||
() => webCtx.webServer.register({ kind: 'prefix', path: PLUGIN_ROUTE, handler: this.serveBundle }),
|
||||
() => webCtx.webServer.register({ kind: 'prefix', path: PLUGIN_ROUTE, resource: request => this.fetchBundle(request) }),
|
||||
'client-modules: bundle route',
|
||||
)
|
||||
}
|
||||
@@ -723,14 +734,16 @@ export class ClientModuleRegistry extends Service {
|
||||
* body. Each bundle body is built once on its first `GET`; script
|
||||
* construction never reads maps. A package asset is read on every request
|
||||
* and answers 304 without a body when `If-None-Match` equals its entity tag;
|
||||
* its body holds the file open until the caller consumes or cancels it.
|
||||
* its body holds the file open until the caller consumes or cancels it. A
|
||||
* bundle, map, or chunk body is the held buffer itself, not a copy, so a
|
||||
* caller must not modify its bytes.
|
||||
* @param request - shell-carrier request for a `/plugins` resource.
|
||||
* @returns the exact response also exposed by the optional Web route.
|
||||
*/
|
||||
async fetchBundle(request: Request): Promise<Response> {
|
||||
const resource = await this.bundleResource(request.method, request.url, request.headers.get('if-none-match') ?? undefined)
|
||||
const body = resource.body === undefined ? null
|
||||
: resource.body instanceof Readable ? transforStreamBody(resource.body) : Uint8Array.from(resource.body)
|
||||
: resource.body instanceof Readable ? transforStreamBody(resource.body) : heldBody(resource.body)
|
||||
return new Response(body, {
|
||||
status: resource.status,
|
||||
...(resource.headers === undefined ? {} : { headers: resource.headers }),
|
||||
@@ -1256,21 +1269,6 @@ export class ClientModuleRegistry extends Service {
|
||||
return { status: 404 }
|
||||
}
|
||||
|
||||
private readonly serveBundle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
||||
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
|
||||
const response = await this.bundleResource(req.method, req.url ?? '/', req.headers['if-none-match'])
|
||||
res.writeHead(response.status, response.headers)
|
||||
if (!(response.body instanceof Readable)) {
|
||||
res.end(response.body)
|
||||
return
|
||||
}
|
||||
try {
|
||||
await pipeline(response.body, res)
|
||||
} catch (error) {
|
||||
// The browser cancelled the request, as leaving an onboarding step does; pipeline already closed the file.
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ERR_STREAM_PREMATURE_CLOSE') throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ClientModuleRegistry
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { Hash } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, utimesSync, writeFileSync } from 'node:fs'
|
||||
import type { IncomingMessage, ServerResponse } from 'node:http'
|
||||
import { SourceMap } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -10,7 +9,7 @@ import { pathToFileURL } from 'node:url'
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import { Context, FiberState, type Fiber } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { renderIndexInjections, type WebServer, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { renderIndexInjections, type WebServer, type WebResourceRoute, type WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import * as modulesClient from '../src/client/index.ts'
|
||||
import { ClientModuleRegistry, bootInjections, orderByModuleGraph } from '../src/index.ts'
|
||||
import type { ClientModuleLoaderTarget, WebBootEntry, WebBootGraph } from '../src/client/index.ts'
|
||||
@@ -68,7 +67,7 @@ const BOOTSTRAP_URL = comboReference([MODULES_ID], 'boot')
|
||||
const APPLICATION_URL = comboReference([UI_RENDERER_ID], 'app')
|
||||
|
||||
let root: string | undefined
|
||||
const contexts: { ctx: Context; ready?: Promise<WebRoute> }[] = []
|
||||
const contexts: { ctx: Context; ready?: Promise<WebResourceRoute> }[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(async ({ ctx, ready }) => {
|
||||
@@ -166,7 +165,7 @@ function constructWithRoute(
|
||||
entryBaseUrl?: string
|
||||
internal?: NonNullable<Context['loader']['internal']>
|
||||
} = {},
|
||||
): { context: Context; service: ClientModuleRegistry; route: Promise<WebRoute> } {
|
||||
): { context: Context; service: ClientModuleRegistry; route: Promise<WebResourceRoute> } {
|
||||
const ctx = new Context()
|
||||
const owned: typeof contexts[number] = { ctx }
|
||||
contexts.push(owned)
|
||||
@@ -184,11 +183,11 @@ function constructWithRoute(
|
||||
}
|
||||
},
|
||||
})
|
||||
const route = Promise.withResolvers<WebRoute>()
|
||||
const route = Promise.withResolvers<WebResourceRoute>()
|
||||
const webServer: Pick<WebServer, 'port' | 'register' | 'tapIndex'> = {
|
||||
port: 0,
|
||||
register: (candidate) => {
|
||||
if (candidate.path === '/plugins') route.resolve(candidate)
|
||||
if (candidate.path === '/plugins' && 'resource' in candidate) route.resolve(candidate)
|
||||
return () => {}
|
||||
},
|
||||
tapIndex: () => () => {},
|
||||
@@ -204,28 +203,15 @@ function construct(packageNames: string[]): ClientModuleRegistry {
|
||||
return constructWithRoute(packageNames).service
|
||||
}
|
||||
|
||||
/** Invoke the registered plugin route and capture status, headers, and bytes. */
|
||||
async function routeRequest(route: Promise<WebRoute>, url: string, method = 'GET'): Promise<{
|
||||
/** Invoke the registered plugin resource and capture status, headers, and bytes. */
|
||||
async function routeRequest(route: Promise<WebResourceRoute>, url: string, method = 'GET'): Promise<{
|
||||
status: number
|
||||
headers: Record<string, string> | undefined
|
||||
headers: Record<string, string>
|
||||
body: Buffer
|
||||
}> {
|
||||
let status = 0
|
||||
let headers: Record<string, string> | undefined
|
||||
let body = Buffer.alloc(0)
|
||||
const response = {
|
||||
writeHead(nextStatus: number, nextHeaders?: Record<string, string>) {
|
||||
status = nextStatus
|
||||
headers = nextHeaders
|
||||
return response
|
||||
},
|
||||
end(chunk?: Uint8Array) {
|
||||
body = chunk === undefined ? Buffer.alloc(0) : Buffer.from(chunk)
|
||||
return response
|
||||
},
|
||||
} as unknown as ServerResponse
|
||||
await (await route).handler({ method, url, headers: {} } as IncomingMessage, response)
|
||||
return { status, headers, body }
|
||||
const { resource } = await route
|
||||
const response = await resource(new Request(new URL(url, 'http://localhost/'), { method }))
|
||||
return { status: response.status, headers: Object.fromEntries(response.headers), body: Buffer.from(await response.arrayBuffer()) }
|
||||
}
|
||||
|
||||
/** Execute the exact first inline script emitted by the Host boot rows. */
|
||||
@@ -903,7 +889,7 @@ describe('client bundle activation', () => {
|
||||
expect(batch).toMatchObject({ phase: 'application', entries: [packageName] })
|
||||
const batchScript = await routeRequest(route, batch.url)
|
||||
expect(batchScript.status).toBe(200)
|
||||
expect(batchScript.headers?.['cache-control']).toBe('public, max-age=31536000, immutable')
|
||||
expect(batchScript.headers['cache-control']).toBe('public, max-age=31536000, immutable')
|
||||
expect(batchScript.body.toString('utf8')).toContain(`//# sourceMappingURL=${mapReference(batch.url)}`)
|
||||
const shellResponse = await service.fetchBundle(new Request(`dsh-app://app/${batch.url}`))
|
||||
expect(shellResponse.status).toBe(200)
|
||||
|
||||
@@ -121,7 +121,10 @@ interface Host {
|
||||
port: number
|
||||
/** Warnings any plugin of the composed Host logged. */
|
||||
warnings: unknown[][]
|
||||
/** Send one raw request target through the Web route and the shell carrier and require identical answers. */
|
||||
/**
|
||||
* Send one raw request target over HTTP, through `webServer.readResource`,
|
||||
* and through `fetchBundle`, and require identical answers.
|
||||
*/
|
||||
both: (path: string, init?: AssetRequestInit) => Promise<Answer>
|
||||
}
|
||||
|
||||
@@ -159,14 +162,19 @@ async function composeHost(): Promise<Host> {
|
||||
outgoing.on('error', reject)
|
||||
outgoing.end()
|
||||
})
|
||||
const reader = { principal: { kind: 'owner' } } as Parameters<WebServer['readResource']>[1]
|
||||
const both = async (path: string, init?: AssetRequestInit): Promise<Answer> => {
|
||||
const answer = await http(path, init)
|
||||
const response = await service.fetchBundle(new Request(`http://localhost${path}`, init))
|
||||
expect({
|
||||
status: response.status,
|
||||
headers: assetHeaders(name => response.headers.get(name)),
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
}).toEqual(answer)
|
||||
for (const response of [
|
||||
await context.webServer.readResource(new Request(`http://localhost${path}`, init), reader),
|
||||
await service.fetchBundle(new Request(`http://localhost${path}`, init)),
|
||||
]) {
|
||||
expect({
|
||||
status: response.status,
|
||||
headers: assetHeaders(name => response.headers.get(name)),
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
}).toEqual(answer)
|
||||
}
|
||||
return answer
|
||||
}
|
||||
// The Web route registers once the injected webServer is ready.
|
||||
@@ -268,9 +276,9 @@ describe('package asset route', () => {
|
||||
})
|
||||
const stream = opened.findLast(entry => entry.path === file)?.stream
|
||||
if (stream === undefined) throw new Error('the asset route opened no file stream')
|
||||
// events.once would reject on the premature-close error the cancelled pipeline reports; only the close matters here.
|
||||
// The cancelled response body closes the file stream; only the close matters here.
|
||||
if (!stream.closed) await new Promise<void>((resolve) => { stream.once('close', () => { resolve() }) })
|
||||
// pipeline settles its rejection in the turn after the file stream closes; the route logs nothing then.
|
||||
// The webserver settles the cancelled exchange in the turn after the file stream closes; nothing is logged then.
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-commands/use-this-package:
|
||||
en: 01adce72791579fa
|
||||
zh: 70eb55a1529038e7
|
||||
en: c325c9eaca4efe47
|
||||
zh: dbbc29cc9cec54bc
|
||||
/deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations:
|
||||
en: 57679a20574d8b3f
|
||||
zh: 98d5e5a7781e6de1
|
||||
|
||||
@@ -25,7 +25,7 @@ Typing a `/` command opens a registered popup, a client action, a host command's
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup holds composer focus: typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
|
||||
Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup automatically focuses its search input on desktop. Phones and tablets require tapping the search input, following the shared [text-input focus policy](../ui-primitives/README.md#use-this-package). With search focused, typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it.
|
||||
|
||||
### Kinds and decorations
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ kind: "package-reference"
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。弹窗持有 composer 焦点:键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
|
||||
与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。桌面端的弹窗自动聚焦搜索框。手机和平板需要点击搜索框,遵循共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。搜索框聚焦后,键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。
|
||||
|
||||
### 种类与装饰
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* Official popupSelect shell: renders one session's PopupSelectController
|
||||
* store into the conversation.input.overlay anchor. Unlike the slash menu
|
||||
* (combobox — textarea keeps focus), this shell HOLDS focus while open: the
|
||||
* inner search input takes focus, plain typing filters the loaded options
|
||||
* locally, Enter and Tab accept the filtered highlight, ↑↓ walk it (wrapping,
|
||||
* (combobox — textarea keeps focus), this shell focuses its search input on
|
||||
* desktop; phones and tablets require explicit search focus. Typing filters
|
||||
* the loaded options locally, Enter and Tab accept the filtered highlight,
|
||||
* ↑↓ walk it (wrapping,
|
||||
* scrolled into view), and Escape and Shift+Tab dismiss back to the composer.
|
||||
* ←→ keep the search input's native caret. Any pointer interaction outside the
|
||||
* box dismisses (the click's own target takes focus). Closed state renders
|
||||
@@ -14,7 +15,7 @@ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/ds
|
||||
import { Fragment, useEffect, useMemo, useRef } from 'react'
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconCheckOutlineRegular, isMobileDevice, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { filterOptions } from './popup.ts'
|
||||
import type { SelectOption } from './contract.ts'
|
||||
@@ -67,8 +68,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' })
|
||||
}, [active, rows, state.confirming])
|
||||
|
||||
// Focus ownership: the search input grabs on open, and ANY outside
|
||||
// pointer interaction dismisses —
|
||||
// Any outside pointer interaction dismisses —
|
||||
// capture phase so a click landing anywhere else (textarea included)
|
||||
// closes the shell before its own handlers run; that click's target then
|
||||
// takes focus naturally, so no focusComposer here.
|
||||
@@ -84,7 +84,9 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) {
|
||||
|
||||
// Focus the search input after it mounts (separate effect so the ref is populated).
|
||||
useEffect(() => {
|
||||
if (state.open && state.confirming === null) searchRef.current?.focus()
|
||||
if (!state.open || state.confirming !== null) return
|
||||
if (isMobileDevice()) return
|
||||
searchRef.current?.focus()
|
||||
}, [state.open, state.confirming])
|
||||
|
||||
if (!state.open) return null
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* PopupSelectView interaction spec: the search input takes
|
||||
* focus on open and plain typing filters locally, ↑↓ move the filtered
|
||||
* focus on desktop open; mobile search requires explicit focus. Typing filters
|
||||
* locally, ↑↓ move the filtered
|
||||
* highlight while ←→ stay native to the input, Enter and Tab select
|
||||
* single-flight, Escape and Shift+Tab dismiss back through focusComposer,
|
||||
* outside pointerdown dismisses
|
||||
@@ -9,7 +10,8 @@
|
||||
* retry button, the highlighted row scrolls into view, and the card height
|
||||
* clamps to the space above the composer.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type { SelectOption } from '../src/client/contract.ts'
|
||||
import type { PopupSpec, TokenSegment } from '../src/client/popup.ts'
|
||||
@@ -140,6 +142,40 @@ describe('PopupSelectView', () => {
|
||||
expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false },
|
||||
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false },
|
||||
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false },
|
||||
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false },
|
||||
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true },
|
||||
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true },
|
||||
])('automatic search focus respects device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => {
|
||||
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
|
||||
Object.defineProperty(navigator, name, { configurable: true, value })
|
||||
onTestFinished(() => {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
|
||||
else Object.defineProperty(navigator, name, descriptor)
|
||||
})
|
||||
}
|
||||
vi.resetModules()
|
||||
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
|
||||
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
|
||||
onTestFinished(() => { device.mockRestore() })
|
||||
const { popup, search } = await mountOpen()
|
||||
expect(document.activeElement === search).toBe(automatic)
|
||||
act(() => { search.focus() })
|
||||
fireEvent.change(search, { target: { value: 'dark' } })
|
||||
expect(document.activeElement).toBe(search)
|
||||
expect(rowLabels()).toEqual(['Dark'])
|
||||
act(() => { popup.dismiss() })
|
||||
await act(async () => {
|
||||
popup.open('theme', spec(), 'ctx-A', SEGMENT)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(document.activeElement === screen.getByRole('textbox', { name: '筛选选项' })).toBe(automatic)
|
||||
})
|
||||
|
||||
it('renders an optional option badge as a superscript marker', async () => {
|
||||
await mountOpen({
|
||||
options: () => Promise.resolve([{ id: 'auto', label: 'Auto review', badge: 'EXP' }]),
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 7871f9fe6bcc4459
|
||||
zh: 0d51565c600b3958
|
||||
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
|
||||
en: e077ba9f974c41e4
|
||||
zh: dc68523ff7ea0c4f
|
||||
en: 511b3650c5a5616b
|
||||
zh: 622faa5b52a870f9
|
||||
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
|
||||
en: bb1244310f5c0382
|
||||
zh: 824b84077432b559
|
||||
|
||||
@@ -70,10 +70,14 @@ The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinat
|
||||
|
||||
The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox.
|
||||
|
||||
The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply.
|
||||
|
||||
The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit.
|
||||
|
||||
The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale).
|
||||
|
||||
The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/[email protected]) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page.
|
||||
|
||||
The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available.
|
||||
|
||||
With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility.
|
||||
|
||||
@@ -70,10 +70,14 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS
|
||||
|
||||
共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。
|
||||
|
||||
在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。
|
||||
|
||||
控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。
|
||||
|
||||
上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。
|
||||
|
||||
composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/[email protected])让光标滚动计算与 DOM 几何使用同一坐标系。
|
||||
|
||||
上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。
|
||||
|
||||
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
|
||||
|
||||
@@ -62,6 +62,9 @@ function $appendParagraphs(text: string): void {
|
||||
/** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */
|
||||
const HISTORY_MERGE_DELAY_MS = 1000
|
||||
|
||||
/** Editor update that removes submitted content while retaining any newer draft suffix. */
|
||||
export const DRAFT_SUBMITTED_TAG = 'dsh-draft-submitted'
|
||||
|
||||
/** One model-owned editor; registration and disposal remain with its model. */
|
||||
export class DraftEditorRuntime {
|
||||
/** The editor bound by the Composer's contenteditable host. */
|
||||
@@ -412,7 +415,7 @@ export class DraftEditorRuntime {
|
||||
const root = $getRoot()
|
||||
root.clear()
|
||||
root.selectEnd()
|
||||
}, { discrete: true, tag: HISTORY_MERGE_TAG })
|
||||
}, { discrete: true, tag: [HISTORY_MERGE_TAG, DRAFT_SUBMITTED_TAG] })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
/** DOM and keymap bindings installed by the InputBar's existing effects. */
|
||||
import type { MouseEvent, MutableRefObject, RefObject } from 'react'
|
||||
import type { LexicalEditor } from 'lexical'
|
||||
import { $getRoot } from 'lexical'
|
||||
import { focusWithoutRing, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ComposerKeyboard } from '../../contract/draft-editor.ts'
|
||||
import type { BusyEnterBehavior } from '../../contract/composer-submission.ts'
|
||||
import { resolveSubmitMode } from '../submission-policy.ts'
|
||||
import { registerComposerKeymap } from './keymap.ts'
|
||||
import { DRAFT_SUBMITTED_TAG } from './runtime.ts'
|
||||
|
||||
/**
|
||||
* Transfer mobile editor focus after a submission empties the draft.
|
||||
* A control the user already focused retains focus.
|
||||
* @param editor - the Session-owned editor.
|
||||
* @param destination - stable, focusable non-text element that retains the composer's keyboard position.
|
||||
* @returns the update-listener cleanup.
|
||||
*/
|
||||
export function installDraftKeyboardDismissal(editor: LexicalEditor, destination: RefObject<HTMLElement>): () => void {
|
||||
return editor.registerUpdateListener(({ editorState, tags }) => {
|
||||
if (!tags.has(DRAFT_SUBMITTED_TAG) || !isMobileDevice()) return
|
||||
const input = editor.getRootElement()
|
||||
if (input === null || input.ownerDocument.activeElement !== input) return
|
||||
if (editorState.read(() => $getRoot().getTextContentSize() === 0) && destination.current !== null) {
|
||||
focusWithoutRing(destination.current, { preventScroll: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface DraftViewGate {
|
||||
locked: boolean
|
||||
|
||||
@@ -138,12 +138,13 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) {
|
||||
{/* figma 34:10412: fish 34×25 leading the headline, gap 10. */}
|
||||
<span
|
||||
className={css.fishHitbox}
|
||||
onMouseEnter={() => {
|
||||
if (window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
|
||||
onPointerEnter={(event) => {
|
||||
if (event.pointerType !== 'touch'
|
||||
&& window.matchMedia('(hover: hover) and (prefers-reduced-motion: no-preference)').matches) {
|
||||
setHovering(true)
|
||||
}
|
||||
}}
|
||||
onMouseLeave={() => { setHovering(false) }}
|
||||
onPointerLeave={() => { setHovering(false) }}
|
||||
>
|
||||
{renderSlot('conversation.hero.brand.mark', { size: 34, className: css.fish }, {
|
||||
fallback: <HeroFish hovering={hovering} />,
|
||||
|
||||
@@ -76,6 +76,12 @@
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.card {
|
||||
font-size: max(16px, var(--dsh-content-font-size, 14px));
|
||||
}
|
||||
}
|
||||
|
||||
/* No-workspace trigger state: an inset 1px dashed outline marks the card as a
|
||||
workspace picker rather than a live composer. Keeping the complete stroke
|
||||
inside the rounded box prevents clipped corner pixels from reading heavier. */
|
||||
|
||||
@@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconPlusOutlineMedium, IconWarningOutlineRegular, Toast, Tooltip,
|
||||
IconPlusOutlineMedium, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: the `plan` projection key merge (the TodoDock posture — the
|
||||
// composer reads a host-computed value; the domain owns the key).
|
||||
@@ -31,7 +31,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ComposerBarProps } from '../contract/slots.ts'
|
||||
import { DraftEditor } from '../input/editor/DraftEditor.tsx'
|
||||
import {
|
||||
focusDraftEditor, installDraftFilePicker, installDraftKeymap, installDraftWheel,
|
||||
focusDraftEditor, installDraftFilePicker, installDraftKeyboardDismissal, installDraftKeymap, installDraftWheel,
|
||||
keepDraftFocus, revealDraftSelection,
|
||||
} from '../input/editor/view-binding.ts'
|
||||
import { resolveSubmitMode } from '../input/submission-policy.ts'
|
||||
@@ -171,13 +171,11 @@ export const InputBar = memo(function InputBar({
|
||||
revealDraftSelection(scrollRef)
|
||||
}
|
||||
|
||||
// Unlock (mount / session switch) returns focus to the box, and owns the
|
||||
// reveal that comes with it. Lexical's focus() suppresses the browser's
|
||||
// scroll walk (preventScroll inside), so the reveal in our own scrollport
|
||||
// is ours to perform — switching to a longer draft otherwise leaves the
|
||||
// caret (restored at the draft's end) off screen.
|
||||
// Automatic desktop focus restores and reveals the draft selection without
|
||||
// scrolling the conversation.
|
||||
useEffect(() => {
|
||||
if (locked || editor === null) return
|
||||
if (isMobileDevice()) return
|
||||
focusDraftEditor(editor, revealSelection)
|
||||
}, [locked, sessionId, editor])
|
||||
|
||||
@@ -268,6 +266,11 @@ export const InputBar = memo(function InputBar({
|
||||
return installDraftKeymap(editor, keyboard, gate)
|
||||
}, [editor, keyboard])
|
||||
|
||||
useEffect(() => {
|
||||
if (editor === null) return
|
||||
return installDraftKeyboardDismissal(editor, cardRef)
|
||||
}, [editor])
|
||||
|
||||
// Button presses steal focus from the editor; suppress at mousedown so
|
||||
// typing continues seamlessly. Lexical's focus() carries preventScroll and
|
||||
// restores the previous selection, so no reveal is needed: the caret has
|
||||
@@ -283,7 +286,7 @@ export const InputBar = memo(function InputBar({
|
||||
if (target.closest('[data-composer-overlay]') !== null) return
|
||||
if (scrollRef.current?.contains(target)) return
|
||||
const control = target.closest('button, a, input, select, textarea, [role="button"], [contenteditable], [tabindex]')
|
||||
if (control !== null && e.currentTarget.contains(control)) return
|
||||
if (control !== null && control !== e.currentTarget && e.currentTarget.contains(control)) return
|
||||
e.preventDefault()
|
||||
// Lexical's stored selection can lag behind native caret moves, so calling
|
||||
// editor.focus() on a focused editor can restore an older caret.
|
||||
@@ -391,6 +394,7 @@ export const InputBar = memo(function InputBar({
|
||||
click's reopen (close-then-open flickers the chip's open echo). */}
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
className={clsx(css.card, workspaceTrigger && css.cardWorkspaceTrigger)}
|
||||
data-composer-card
|
||||
onClick={workspaceTrigger ? onRequestWorkspace : undefined}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Composer selection survives focus transfers and background editor reconciliation. */
|
||||
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { $getRoot, $getSelection, $isRangeSelection, SELECTION_CHANGE_COMMAND, UNDO_COMMAND } from 'lexical'
|
||||
import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts'
|
||||
import { focusDraftEditor } from '../src/client/input/editor/view-binding.ts'
|
||||
import { focusDraftEditor, installDraftKeyboardDismissal } from '../src/client/input/editor/view-binding.ts'
|
||||
|
||||
function fixture(start = 1, end = 4) {
|
||||
// jsdom has no Range geometry; these cases observe selection and focus, not scrolling.
|
||||
@@ -16,9 +17,12 @@ function fixture(start = 1, end = 4) {
|
||||
const root = document.createElement('div')
|
||||
root.setAttribute('contenteditable', 'true')
|
||||
root.tabIndex = 0
|
||||
const card = document.createElement('div')
|
||||
card.tabIndex = -1
|
||||
card.append(root)
|
||||
const outside = document.createElement('button')
|
||||
document.body.append(root, outside)
|
||||
onTestFinished(() => { root.remove(); outside.remove() })
|
||||
document.body.append(card, outside)
|
||||
onTestFinished(() => { card.remove(); outside.remove() })
|
||||
// jsdom collapses document Selection on every focus(); Chromium retains the
|
||||
// editor range here. Keep native focus events and activeElement changes.
|
||||
for (const element of [root, outside]) {
|
||||
@@ -66,7 +70,7 @@ function fixture(start = 1, end = 4) {
|
||||
editor.read(() => {})
|
||||
return reveal
|
||||
}
|
||||
return { runtime, editor, root, outside, onUpdate, dispose, selection, domSelection, focusComposer }
|
||||
return { runtime, editor, root, card, outside, onUpdate, dispose, selection, domSelection, focusComposer }
|
||||
}
|
||||
|
||||
it.each([
|
||||
@@ -138,6 +142,48 @@ it('restores a pending selection when blur and explicit focus share one update b
|
||||
expect(reveal).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hands mobile submission focus to the composer without stealing focus from another control', async () => {
|
||||
const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1')
|
||||
onTestFinished(() => { userAgent.mockRestore() })
|
||||
vi.resetModules()
|
||||
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
|
||||
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
|
||||
onTestFinished(() => { device.mockRestore() })
|
||||
const f = fixture()
|
||||
const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card })
|
||||
onTestFinished(dispose)
|
||||
|
||||
f.runtime.clearCommittedDraft(() => null)
|
||||
expect(f.root.textContent).toBe('')
|
||||
expect(document.activeElement).toBe(f.card)
|
||||
|
||||
f.runtime.setDraft('next draft')
|
||||
f.outside.focus()
|
||||
f.runtime.clearCommittedDraft(() => null)
|
||||
expect(document.activeElement).toBe(f.outside)
|
||||
})
|
||||
|
||||
it('retains mobile focus for a newer draft suffix and removes dismissal with the view', async () => {
|
||||
const userAgent = vi.spyOn(navigator, 'userAgent', 'get').mockReturnValue('Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1')
|
||||
onTestFinished(() => { userAgent.mockRestore() })
|
||||
vi.resetModules()
|
||||
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
|
||||
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
|
||||
onTestFinished(() => { device.mockRestore() })
|
||||
const f = fixture()
|
||||
const dispose = installDraftKeyboardDismissal(f.editor, { current: f.card })
|
||||
onTestFinished(dispose)
|
||||
|
||||
f.runtime.clearCommittedDraft(() => 6)
|
||||
expect(f.root.textContent).toBe('beta')
|
||||
expect(document.activeElement).toBe(f.root)
|
||||
|
||||
dispose()
|
||||
f.runtime.clearCommittedDraft(() => null)
|
||||
expect(f.root.textContent).toBe('')
|
||||
expect(document.activeElement).toBe(f.root)
|
||||
})
|
||||
|
||||
it('removes focus preservation and update subscriptions when registration is disposed', () => {
|
||||
const f = fixture()
|
||||
f.dispose()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Composer caret visibility across page scrolling and virtual-keyboard viewport changes. */
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { DraftEditorRuntime } from '../src/client/input/editor/runtime.ts'
|
||||
|
||||
function fixture(viewport: { height: number; offsetTop: number; pageTop: number } | undefined, pageScroll: number) {
|
||||
// jsdom has no visual viewport or Range geometry; use the browser measurements
|
||||
// while leaving Lexical's DOM selection and scrolling decisions intact.
|
||||
vi.stubGlobal('visualViewport', viewport)
|
||||
vi.stubGlobal('scrollY', pageScroll)
|
||||
vi.stubGlobal('innerHeight', 600)
|
||||
const scrollBy = vi.spyOn(window, 'scrollBy').mockImplementation(() => {})
|
||||
const geometry = Object.getOwnPropertyDescriptor(Range.prototype, 'getBoundingClientRect')
|
||||
let caret = new DOMRect(20, 169, 0, 26)
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => caret,
|
||||
})
|
||||
const root = document.createElement('div')
|
||||
root.setAttribute('contenteditable', 'true')
|
||||
root.tabIndex = 0
|
||||
root.getBoundingClientRect = () => new DOMRect(0, 0, 400, 1000)
|
||||
document.body.append(root)
|
||||
const runtime: DraftEditorRuntime = new DraftEditorRuntime({
|
||||
onUpdate: () => { runtime.refreshProjection() },
|
||||
quoteLabels: () => ({ remove: 'Remove quote' }),
|
||||
openReference: () => false,
|
||||
lexicon: () => new Map(), resolveLexicon: () => undefined,
|
||||
})
|
||||
const dispose = runtime.register()
|
||||
onTestFinished(() => {
|
||||
dispose()
|
||||
root.remove()
|
||||
scrollBy.mockRestore()
|
||||
if (geometry === undefined) Reflect.deleteProperty(Range.prototype, 'getBoundingClientRect')
|
||||
else Object.defineProperty(Range.prototype, 'getBoundingClientRect', geometry)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
runtime.editor.setRootElement(root)
|
||||
root.focus({ preventScroll: true })
|
||||
scrollBy.mockClear()
|
||||
return {
|
||||
scrollBy,
|
||||
typeAt(top: number): void {
|
||||
caret = new DOMRect(20, top, 0, 26)
|
||||
runtime.setDraft('a')
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
it('keeps a visible caret still after Safari pans the page above the keyboard', () => {
|
||||
const f = fixture({ height: 376, offsetTop: 196, pageTop: 196 }, 196)
|
||||
f.typeAt(169)
|
||||
expect(f.scrollBy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reveals a caret below the keyboard after the page has scrolled', () => {
|
||||
const f = fixture({ height: 376, offsetTop: 196, pageTop: 196 }, 196)
|
||||
f.typeAt(410)
|
||||
expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, 60)
|
||||
})
|
||||
|
||||
it('retains visual viewport panning relative to an already scrolled page', () => {
|
||||
const f = fixture({ height: 376, offsetTop: 150, pageTop: 350 }, 200)
|
||||
f.typeAt(100)
|
||||
expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, -50)
|
||||
})
|
||||
|
||||
it('uses the window height when the browser has no visual viewport', () => {
|
||||
const f = fixture(undefined, 200)
|
||||
f.typeAt(630)
|
||||
expect(f.scrollBy).toHaveBeenCalledExactlyOnceWith(0, 56)
|
||||
})
|
||||
@@ -8,6 +8,7 @@
|
||||
// root listener routes them through the keymap commands); draft writes drive
|
||||
// the shell (jsdom's beforeinput lacks the ranges Lexical needs).
|
||||
|
||||
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import './control-row-dom.ts'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -1449,6 +1450,81 @@ describe('running and lock semantics', () => {
|
||||
expect(focused).toEqual([true])
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false },
|
||||
{ name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false },
|
||||
{ name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false },
|
||||
{ name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false },
|
||||
{ name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true },
|
||||
{ name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true },
|
||||
])('composer focus and submission respect device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => {
|
||||
for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(navigator, name)
|
||||
Object.defineProperty(navigator, name, { configurable: true, value })
|
||||
onTestFinished(() => {
|
||||
if (descriptor === undefined) Reflect.deleteProperty(navigator, name)
|
||||
else Object.defineProperty(navigator, name, descriptor)
|
||||
})
|
||||
}
|
||||
vi.resetModules()
|
||||
const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts')
|
||||
const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice)
|
||||
onTestFinished(() => { device.mockRestore() })
|
||||
const outside = document.createElement('input')
|
||||
document.body.append(outside)
|
||||
onTestFinished(() => { outside.remove() })
|
||||
outside.focus()
|
||||
|
||||
const { view, textarea, props, shell, button, sink } = bench({ draft: 'draft' })
|
||||
expect(document.activeElement).toBe(automatic ? textarea : outside)
|
||||
act(() => { outside.focus() })
|
||||
act(() => { view.rerender(<InputBar {...props} sessionId={'s2' as SessionId} />) })
|
||||
expect(document.activeElement).toBe(automatic ? textarea : outside)
|
||||
act(() => { outside.focus() })
|
||||
act(() => { view.rerender(<InputBar {...props} disabled />) })
|
||||
act(() => { view.rerender(<InputBar {...props} />) })
|
||||
expect(document.activeElement).toBe(automatic ? textarea : outside)
|
||||
|
||||
act(() => { outside.focus() })
|
||||
fireEvent.mouseDown(view.container.querySelector('[data-composer-card]')!, { button: 0 })
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
|
||||
writeDraft(shell, '')
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
expect(sink).not.toHaveBeenCalled()
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
|
||||
for (const gesture of ['click', 'enter'] as const) {
|
||||
writeDraft(shell, `send by ${gesture}`)
|
||||
act(() => { textarea.focus() })
|
||||
if (gesture === 'click') {
|
||||
fireEvent.mouseDown(button, { button: 0 })
|
||||
fireEvent.click(button)
|
||||
} else {
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
}
|
||||
await act(async () => {})
|
||||
expect(sink).toHaveBeenLastCalledWith(`send by ${gesture}`, [], 'queue', expect.any(AbortSignal))
|
||||
expect(shell.snapshot.draft).toBe('')
|
||||
expect(document.activeElement === textarea).toBe(automatic)
|
||||
}
|
||||
|
||||
writeDraft(shell, '/goal ')
|
||||
act(() => {
|
||||
shell.beginCommand(
|
||||
{ name: 'goal', token: '/goal ', submit: () => Promise.resolve({ kind: 'error', text: 'Missing goal' }) },
|
||||
{ start: 0, end: 6, draftRev: shell.snapshot.draftRev },
|
||||
)
|
||||
textarea.focus()
|
||||
})
|
||||
fireEvent.keyDown(textarea, { key: 'Enter' })
|
||||
await act(async () => {})
|
||||
expect(shell.snapshot.phase).toBe('claimed')
|
||||
expect(shell.snapshot.draft).toBe('/goal ')
|
||||
expect(document.activeElement).toBe(textarea)
|
||||
})
|
||||
|
||||
it('a persisted draft adopted after mount does not steal focus from another control', () => {
|
||||
const { shell } = bench()
|
||||
const other = document.createElement('input')
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 42038fdc402b9c2a
|
||||
zh: 99890f8f908624ce
|
||||
/deepseek-ai-dsh-client-ui-dockkit/the-two-layers:
|
||||
en: 99356ea8e3d1e7aa
|
||||
zh: 870f746730d0e6cf
|
||||
en: 718f52cfce426009
|
||||
zh: e8282e2e3713d242
|
||||
/deepseek-ai-dsh-client-ui-dockkit/embedding-it:
|
||||
en: c4ba08038bfa6f30
|
||||
zh: 95db30618b52babe
|
||||
|
||||
@@ -37,10 +37,12 @@ A docking layout kit: a split tree of tabbed panes with invertible operations, a
|
||||
- `planSettle` is the opt-in rule that keeps every docked pane populated after an intent: panes an intent emptied are merged away, and an emptied root pane is reseeded through the embedder's factory — withholding the factory keeps the merge and leaves the root pane empty. An embedder that wants empty panes simply does not call it, or calls it without a factory. `planDropTab` takes the same factory: with one, a sole tab released on its own pane's edge splits and the factory's tab backfills the pane it vacates (the dragged tab stays focused); without one that release changes nothing.
|
||||
- `DockController` is the intent layer and an observable source (`subscribe` + `getSnapshot`, whose reference only changes when the layout does).
|
||||
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports the caret slot as drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
**The components** render a layout snapshot and report settled intents — one per gesture, never a drag frame. A drag previews in local state while the gesture's own facts stay in its closure; on release the net result leaves through one `DockIntents` call — a strip release reports a placement only when a caret is drawn, the dragged chip counted, and `planPlaceTab` turns that into the reorder or the move. That is what lets an embedder record exactly one history entry per gesture. The strip follows the WAI-ARIA tabs pattern with manual activation: the selected chip is in the tab order; Left and Right (wrapping), Home, and End move focus between chips without selecting; Enter or Space selects the focused chip through the same intent as a click. A chip is a capsule carrying one control, its close; the context menu (a secondary press on the chip) carries the same close plus the embedder's items — a menu that would hold no item at all never shows — and renders in a portal positioned against the chip because the chip box clips its overflow on purpose (see below). After the chips sits the add control, which asks the embedder (`DockIntents.addTab`) to seat its seeded tab; the embedder's `canAddTab(paneId)` decides per pane whether the control is drawn at all. Copying a tab has no kit control — it is the embedder's API — and floating is the drag released clear of the surface.
|
||||
|
||||
Middle-clicking anywhere on a closable tab chip, including its close control, closes it through the same intent as its close button, without selecting it first. The chip owns the middle click and suppresses nested title content’s default action. Closing a background tab preserves the selected tab. Only the primary button starts tab dragging; a middle click while another mouse button is held does not close the tab.
|
||||
|
||||
Strip slots that preserve the dragged tab’s position show no insertion caret and report no placement; the tab can still be dragged to split a pane or float.
|
||||
|
||||
Tab context menus show the close action name without a shortcut hint: they act on the clicked tab, while shortcuts can target another focused pane. An unmodified Escape dismisses the foreground tab menu without closing the tab and returns focus from a menu item to its tab without drawing a focus outline. Tab and arrow navigation retain the tab’s visible focus indicator. Composition and held-key repeats do not dismiss it.
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
|
||||
@@ -37,10 +37,12 @@ kind: "package-reference"
|
||||
- `planSettle` 是可选加入的规则,保证意图之后每个停靠格都有内容:被意图清空的格会被并掉,被清空的根格通过嵌入方的工厂重新播种——不传工厂则只并格、让根格保持为空。想要空格的嵌入方只需不调用它,或不带工厂调用。`planDropTab` 接受同一工厂:带工厂时,唯一 tab 放到本格边缘会分栏,工厂的 tab 回填它腾出的格(被拖的 tab 保持聚焦);不带工厂时这种释放不改变任何东西。
|
||||
- `DockController` 是意图层,也是一个可观察源(`subscribe` + `getSnapshot`,其引用只在布局变化时才变)。
|
||||
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手上报的是按绘制顺序数出的插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
**组件**渲染布局快照并上报已落定的意图——每次手势一条,绝不上报拖动帧。拖动过程中在本地状态里预览,手势自身的事实留在它的闭包里;松手时净结果通过一次 `DockIntents` 调用离开——在标签条上松手时,仅当定位条可见才上报插入槽位(被拖的 chip 也计入),由 `planPlaceTab` 换算成重排或移动。正是这一点让嵌入方能为每次手势记录恰好一条历史。标签条遵循 WAI-ARIA tabs 模式的手动激活:选中的 chip 在 Tab 键序里;左右方向键(循环)、Home、End 只在 chip 之间移动焦点而不选中;Enter 或空格选中当前聚焦的 chip,走与点击相同的意图。chip 是一个胶囊,携带唯一的控件——它的关闭按钮;上下文菜单(在 chip 上的次键按下)携带同样的关闭项加上嵌入方的条目——一个连一项都没有的菜单绝不展示——并渲染在按 chip 定位的 portal 里,因为 chip 盒会故意裁掉溢出(见下文)。chip 之后是添加控件,它请嵌入方(`DockIntents.addTab`)安放其种子 tab;嵌入方的 `canAddTab(paneId)` 按格决定是否绘制该控件。复制 tab 没有套件控件——那是嵌入方的 API——而浮出就是把拖动松手在停靠区之外。
|
||||
|
||||
中键点击可关闭标签页 chip 的任意位置(包括关闭控件)时,通过与关闭按钮相同的意图关闭,无需先选中。chip 接管中键点击,并阻止嵌套标题内容的默认动作。关闭后台标签页会保留当前选中的标签页。只有主键会开始拖动标签页;其它鼠标按键仍按住时,中键点击不会关闭标签页。
|
||||
|
||||
不改变所拖标签位置的标签条落点不显示插入定位条,也不上报放置操作;标签仍可拖出分栏或浮动。
|
||||
|
||||
标签上下文菜单只显示关闭操作名,不显示快捷键:菜单操作针对被点击的标签,快捷键则可能作用于其他焦点分栏。无修饰键的 Esc 关闭前台标签菜单而不关闭标签,并将菜单项的焦点交还标签,不绘制焦点外轮廓线。Tab 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。
|
||||
|
||||
<a id="embedding-it"></a>
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*
|
||||
* A gesture only previews until it ends, then leaves through one intent, so the
|
||||
* embedder's operation sequence stays the single source of truth. Releasing a tab
|
||||
* clear of this surface floats it; releasing inside it but on no pane is not a
|
||||
* move at all.
|
||||
* clear of this surface floats it; releasing inside it without an effective
|
||||
* drop target changes nothing.
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
@@ -110,12 +110,15 @@ const ALWAYS = (): boolean => true
|
||||
/**
|
||||
* Resolve where a pointer sits inside the docked surface. An edge zone is only
|
||||
* offered where the split it would make is allowed: within the pane budget and
|
||||
* with room for two halves; otherwise the release is not a move at all.
|
||||
* with room for two halves. Strip slots that preserve the dragged tab's position
|
||||
* offer no caret or placement intent. An undefined target inside the surface
|
||||
* leaves the layout unchanged on release.
|
||||
*/
|
||||
function hitTest(
|
||||
root: HTMLElement,
|
||||
x: number,
|
||||
y: number,
|
||||
tabId: TabId,
|
||||
canSplit: boolean,
|
||||
fits: ReadonlyMap<PaneId, HalvesFit>,
|
||||
dropZones: 'edges' | 'horizontal',
|
||||
@@ -126,7 +129,10 @@ function hitTest(
|
||||
const strip = pane.querySelector<HTMLElement>('[data-dockkit-strip]')
|
||||
if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) {
|
||||
const tabs = [...strip.querySelectorAll<HTMLElement>('[data-dockkit-tab]')]
|
||||
return { kind: 'strip', paneId, index: insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x) }
|
||||
const index = insertionIndex(tabs.map(tab => tab.getBoundingClientRect()), x)
|
||||
const sourceIndex = tabs.findIndex(tab => tab.dataset.dockkitTab === tabId)
|
||||
if (sourceIndex >= 0 && (index === sourceIndex || index === sourceIndex + 1)) return undefined
|
||||
return { kind: 'strip', paneId, index }
|
||||
}
|
||||
const zone = dropZones === 'horizontal'
|
||||
? canSplit && fitOf(fits, paneId).row
|
||||
@@ -229,12 +235,12 @@ function Surface({
|
||||
setPreview({
|
||||
...NO_PREVIEW,
|
||||
draggingTabId: tabId,
|
||||
dropTarget: hitTest(root, moved.clientX, moved.clientY, canSplit, fits, dropZones),
|
||||
dropTarget: hitTest(root, moved.clientX, moved.clientY, tabId, canSplit, fits, dropZones),
|
||||
})
|
||||
},
|
||||
up: (released) => {
|
||||
if (!dragging) return
|
||||
const target = hitTest(root, released.clientX, released.clientY, canSplit, fits, dropZones)
|
||||
const target = hitTest(root, released.clientX, released.clientY, tabId, canSplit, fits, dropZones)
|
||||
if (target === undefined) {
|
||||
if (containsPoint(root.getBoundingClientRect(), released.clientX, released.clientY)) return
|
||||
intents.floatTab(tabId, floatRectAt(released.clientX, released.clientY, FLOAT_DEFAULT_SIZE))
|
||||
|
||||
@@ -999,21 +999,42 @@ describe('tab drags', () => {
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
})
|
||||
|
||||
it('previews a caret at the slot under the pointer and reports that slot on release', () => {
|
||||
it('previews only strip slots that change the tab order', () => {
|
||||
const { intents, first, fileTabId, chip } = twoPanes()
|
||||
// Left of the first chip's midpoint: slot 0.
|
||||
drag(chip(fileTabId), FILE_CHIP, [30, 18], false)
|
||||
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('0')
|
||||
// Past both chips: the slot after the last one, drawn at the strip's end.
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 300, clientY: 18 })
|
||||
expect(document.querySelector('[data-dockkit-caret]')?.getAttribute('data-dockkit-caret')).toBe('2')
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 300, clientY: 18 })
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 120, clientY: 18 })
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
fireEvent.pointerMove(window, { pointerId: 7, clientX: 30, clientY: 18 })
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 30, clientY: 18 })
|
||||
// The slot counts the dragged chip itself; the planner turns it into a reorder.
|
||||
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 2)
|
||||
expect(intents.placeTab).toHaveBeenCalledWith(fileTabId, first, 0)
|
||||
expect(gestureIntents(intents)).toEqual(['placeTab'])
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([30, 300])('does not offer a strip placement for the lone Start tab at x=%s', (x) => {
|
||||
const controller = seededController()
|
||||
controller.setExpanded(true)
|
||||
const panes = dockPaneIds(controller.getSnapshot().state)
|
||||
layOut(panes, 520)
|
||||
const intents = spyIntents()
|
||||
renderSurface(controller, intents)
|
||||
const chip = screen.getByRole('tab')
|
||||
drag(chip, [51, 18], [x, 18], false)
|
||||
expect(document.querySelector('[data-dockkit-caret]')).toBeNull()
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: x, clientY: 18 })
|
||||
expect(gestureIntents(intents)).toEqual([])
|
||||
|
||||
drag(chip, [51, 18], [260, 590], false)
|
||||
expect(document.querySelector('[data-dockkit-drop-active]')?.getAttribute('data-dockkit-dock-zone')).toBe('bottom')
|
||||
fireEvent.pointerUp(window, { pointerId: 7, clientX: 260, clientY: 590 })
|
||||
expect(intents.dropTab).toHaveBeenCalledExactlyOnceWith(chip.getAttribute('data-dockkit-tab'), panes[0], 'bottom')
|
||||
})
|
||||
|
||||
it('reports a slot in another pane\'s strip as a placement there', () => {
|
||||
const { intents, second, fileTabId, chip } = twoPanes()
|
||||
drag(chip(fileTabId), FILE_CHIP, [420 + 30, 18])
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package:
|
||||
en: ef50e68e7493d0a9
|
||||
zh: 3b1faea17b606173
|
||||
en: 9b1eca0aec81ac29
|
||||
zh: 9a1599c3abf76513
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-and-effort:
|
||||
en: 48f8157ffdfe6e69
|
||||
zh: 64ec9a533e2aa8f4
|
||||
en: 2e6d9965e01dc0ce
|
||||
zh: 332cc82233eb02f3
|
||||
/deepseek-ai-dsh-client-ui-model-selection/use-this-package/model-label-extensions:
|
||||
en: bc47871cf8b61451
|
||||
zh: 17d21da46604f071
|
||||
|
||||
@@ -31,13 +31,13 @@ DeepSeek account and API-key routes appear as separate provider groups, each exp
|
||||
|
||||
When no model is selected or the saved selection is absent from the catalog, the composer shows “Select model” in regular weight and hides the reasoning effort caption. Clicking it opens the model list directly; Escape and Shift+Tab close it. An empty catalog displays “No models available.”
|
||||
|
||||
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown, otherwise the current model or effort row, and going back lands on the cell that opened the pane left. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption.
|
||||
Mount this plugin alongside `ui-conversation` and the commands package; the composer then shows the model seat next to the pending indicator, and `/model` opens the same directory as a popup. While the seat's menu is open, `↑`/`↓` move focus through root and effort rows, or move the model-list highlight without leaving search. Enter and Tab settle the highlighted model or focused row; Escape and `Shift+Tab` leave a drilled pane first and otherwise close back to the trigger. Drilling focuses model search when shown on desktop devices; otherwise it focuses the current model or effort row, or the first enabled row when none is selected. Going back focuses the cell that opened the pane. Phones and tablets leave search unfocused until the user taps it or uses keyboard navigation. The composer shows the catalog name while the selected model is available. Removing a model or provider, including account sign-out, preserves the stored provider, model, and reasoning effort without displaying their IDs. Restoring the catalog entry restores its name and effort caption.
|
||||
|
||||
Mouse selection uses native browser clicks, including their cancellation behavior; a press alone never selects. Opening the root menu focuses its trigger, and clicking the trigger again closes the menu and returns focus there. Root rows and the search-clear button show the same fill for keyboard focus and hover, without a native outline. Selecting a model or effort returns focus to the trigger without a focus ring; leaving the trigger or reopening the menu restores its normal focus indication. While a selection from either entry is pending, focus stays on the trigger, the trigger shows a spinner in place of its chevron, and each row whose value the selection carries shows one in place of its check; a rejected selection leaves the menu open. Tab returns to model search with the current model highlighted when search is shown, otherwise to the current row.
|
||||
|
||||
### Model and effort
|
||||
|
||||
The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs focus the current model or first row and support keyboard navigation directly. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
|
||||
The button's model menu shows search only when its full catalog contains more than four models; smaller catalogs support keyboard navigation directly and focus the current model or first enabled row on all devices. The `/model` command always keeps its search field. Both search fields match model names case-insensitively, including nonconsecutive characters in order; leading and trailing spaces are ignored. Within each provider, prefix matches come first, followed by alignment score and then catalog order. Empty groups are hidden. Empty catalogs and queries without matches are announced through a status region. Search keeps focus while arrow keys cycle the highlighted result across provider groups; Enter or Tab selects it. Left and right arrows keep their native caret behavior. Opening highlights the current model or the first available row, and editing the query resets the highlight to the first result. Reopening the model pane clears the query.
|
||||
|
||||
Model and reasoning-effort names use weight 400 (regular) in the composer menu, including the selected item. The search field follows the command popup's compact treatment with transparent background and border in both palettes, with no leading icon and a caption-tone placeholder. The clear button appears for a nonempty query and restores the full list with focus in the search field.
|
||||
|
||||
|
||||
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user