mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-10-26 16:55:08 +00:00
Merge master into personalized pages experiment
This commit is contained in:
commit
80997ea36a
172 files changed
+5383
-763
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`。
|
||||
+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/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch)
|
||||
- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch)
|
||||
- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
|
||||
- `lexical@0.49.0` — [`patches/lexical@0.49.0.patch`](patches/lexical@0.49.0.patch)
|
||||
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
|
||||
|
||||
## 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/pkg@6.21.0': { scope: 'workspace-only', reason: 'The executable packer executes on the build host.' },
|
||||
'app-builder-lib@26.17.0': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
|
||||
'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
|
||||
'lexical@0.49.0': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
|
||||
'node-pty@1.2.0-beta.15': { 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/pi-ai@1.3.0', 'node-pty@1.2.0-beta.15'])
|
||||
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.
|
||||
@@ -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'),
|
||||
@@ -1259,7 +1437,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()
|
||||
|
||||
@@ -89,6 +89,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",
|
||||
|
||||
@@ -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') },
|
||||
],
|
||||
|
||||
@@ -2499,7 +2499,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. */
|
||||
@@ -2514,6 +2514,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. */
|
||||
@@ -4285,7 +4287,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. */
|
||||
@@ -4382,7 +4384,8 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-user-approval`
|
||||
|
||||
- `source`: [`packages/interaction/user-approval/src/index.ts:135`](../packages/interaction/user-approval/src/index.ts)
|
||||
- `inject`: `sessionProjections`
|
||||
- `source`: [`packages/interaction/user-approval/src/index.ts:147`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -4475,7 +4478,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. */
|
||||
@@ -4526,7 +4529,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). */
|
||||
|
||||
@@ -2501,7 +2501,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. */
|
||||
@@ -2516,6 +2516,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. */
|
||||
@@ -4287,7 +4289,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. */
|
||||
@@ -4384,7 +4386,8 @@ export interface Config {
|
||||
|
||||
## `@deepseek-ai/dsh-user-approval`
|
||||
|
||||
- `source`: [`packages/interaction/user-approval/src/index.ts:135`](../packages/interaction/user-approval/src/index.ts)
|
||||
- `inject`: `sessionProjections`
|
||||
- `source`: [`packages/interaction/user-approval/src/index.ts:147`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config. All optional — `static Config` supplies the defaults. */
|
||||
@@ -4477,7 +4480,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. */
|
||||
@@ -4528,7 +4531,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). */
|
||||
|
||||
@@ -689,6 +689,7 @@ flowchart TD
|
||||
pkg_user_approval --> pkg_llm
|
||||
pkg_user_approval --> pkg_scope
|
||||
pkg_user_approval --> pkg_session
|
||||
pkg_user_approval --> pkg_session_projection
|
||||
pkg_user_approval --> pkg_system_prompt
|
||||
pkg_user_questions --> pkg_agent
|
||||
pkg_user_questions --> pkg_llm
|
||||
@@ -1691,7 +1692,7 @@ flowchart TD
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`util-values`](../packages/util/values) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
|
||||
@@ -691,6 +691,7 @@ flowchart TD
|
||||
pkg_user_approval --> pkg_llm
|
||||
pkg_user_approval --> pkg_scope
|
||||
pkg_user_approval --> pkg_session
|
||||
pkg_user_approval --> pkg_session_projection
|
||||
pkg_user_approval --> pkg_system_prompt
|
||||
pkg_user_questions --> pkg_agent
|
||||
pkg_user_questions --> pkg_llm
|
||||
@@ -1693,7 +1694,7 @@ flowchart TD
|
||||
| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell) |
|
||||
| [`deepseek-account`](../packages/credentials/deepseek-account) | `credentials` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`util-values`](../packages/util/values) |
|
||||
| [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-questions`](../packages/interaction/user-questions) | `interaction` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`jobs`](../packages/jobs/jobs) | `jobs` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`workspace`](../packages/workspace/workspace) |
|
||||
| [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt) |
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 1e65aa4c24a595ee
|
||||
zh: fe7f527c5e45d6a6
|
||||
/session-persistence-event-catalog/events/approval/approval-policy-log-only:
|
||||
en: d520080fbb669b54
|
||||
zh: a4459a534c1c96c5
|
||||
en: 13ecb3c4d92a39e2
|
||||
zh: 98ca36fea1b799ec
|
||||
/session-persistence-event-catalog/events/assistant:
|
||||
en: 44426b6cd0dc01cf
|
||||
zh: 44426b6cd0dc01cf
|
||||
@@ -972,8 +972,8 @@
|
||||
en: bb7b3652609ac651
|
||||
zh: bdde4e652413c5eb
|
||||
/session-persistence-event-catalog/resolved-persistence-types/approvalpolicy:
|
||||
en: 670f484e05d50fa7
|
||||
zh: 7c107f338ea6cf81
|
||||
en: c78bdd273133a02f
|
||||
zh: 2cbbc4d9c7e0a726
|
||||
/session-persistence-event-catalog/resolved-persistence-types/assistantmessage:
|
||||
en: 2a8bc13a30855b26
|
||||
zh: 87f1f5fd2882a411
|
||||
@@ -1908,8 +1908,8 @@
|
||||
en: 3028b222670ce5ad
|
||||
zh: cae4c9437e560d61
|
||||
/session-persistence-event-catalog/resolved-persistence-types/policy-source:
|
||||
en: 00f1d435f82d2753
|
||||
zh: c2ab108ad2748a6c
|
||||
en: 88fd92dff7aa505f
|
||||
zh: b400712b3ef749aa
|
||||
/session-persistence-event-catalog/resolved-persistence-types/preset:
|
||||
en: 8ebd37343b638773
|
||||
zh: bf7ffa9a60ca42a8
|
||||
|
||||
@@ -273,7 +273,7 @@ Source: [`packages/interaction/user-approval/src/types.ts:55`](../packages/inter
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/interaction/user-approval/src/index.ts:40`](../packages/interaction/user-approval/src/index.ts)
|
||||
Source: [`packages/interaction/user-approval/src/index.ts:41`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
### `assistant/*`
|
||||
|
||||
@@ -3167,7 +3167,7 @@ One of:
|
||||
|
||||
SHA-256: `05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf`
|
||||
|
||||
Sources: [`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts)
|
||||
Sources: [`packages/interaction/user-approval/src/index.ts:68`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
One of:
|
||||
|
||||
@@ -7877,7 +7877,7 @@ Sources: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/
|
||||
|
||||
SHA-256: `11f70b595aa76897fc4e25a352bfa718c659f44b799e5aa14e84e5a38fe72912`
|
||||
|
||||
Sources: [`packages/interaction/user-approval/src/index.ts:40`](../packages/interaction/user-approval/src/index.ts)
|
||||
Sources: [`packages/interaction/user-approval/src/index.ts:41`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
| Property | Presence | Type |
|
||||
|---|---|---|
|
||||
|
||||
@@ -275,7 +275,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
|
||||
}
|
||||
```
|
||||
|
||||
来源:[`packages/interaction/user-approval/src/index.ts:40`](../packages/interaction/user-approval/src/index.ts)
|
||||
来源:[`packages/interaction/user-approval/src/index.ts:41`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
### `assistant/*`
|
||||
|
||||
@@ -3169,7 +3169,7 @@ SHA-256: `930c1cd289def119d443e19dd90008916bf035f0a4d23e07241e7f050f2b89b7`
|
||||
|
||||
SHA-256: `05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf`
|
||||
|
||||
来源:[`packages/interaction/user-approval/src/index.ts:67`](../packages/interaction/user-approval/src/index.ts)
|
||||
来源:[`packages/interaction/user-approval/src/index.ts:68`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
以下类型之一:
|
||||
|
||||
@@ -7879,7 +7879,7 @@ SHA-256: `9890113e97dd3541787bfe6704f2b223d3a890b5325ca0acae2b0398909b413e`
|
||||
|
||||
SHA-256: `11f70b595aa76897fc4e25a352bfa718c659f44b799e5aa14e84e5a38fe72912`
|
||||
|
||||
来源:[`packages/interaction/user-approval/src/index.ts:40`](../packages/interaction/user-approval/src/index.ts)
|
||||
来源:[`packages/interaction/user-approval/src/index.ts:41`](../packages/interaction/user-approval/src/index.ts)
|
||||
|
||||
| 属性 | 存在性 | 类型 |
|
||||
|---|---|---|
|
||||
|
||||
Generated
+2
-2
@@ -75,7 +75,7 @@
|
||||
{"digest":"02b2c23f2d94b028c9588a8faace3fce1217405db346da3389368b495457bf79","names":[],"sources":["packages/extensions/cordis-host-runner/src/index.ts:14"]},
|
||||
{"digest":"0341d4c3dfcd53253cdc1e789d9130d3cd848b6f8b0706bf92f313a68b39ff23","names":[],"sources":[]},
|
||||
{"digest":"05a2796621b1ee6c03ef7abe560b25116bb4da4d592312117242a78415a32b04","names":[],"sources":[]},
|
||||
{"digest":"05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf","names":["packages/interaction/user-approval/src/index.ts#ApprovalPolicy"],"sources":["packages/interaction/user-approval/src/index.ts:67"]},
|
||||
{"digest":"05d2ae1e0c7f8770f83f9a09047afb39a335d8ac986df3193659aa1e581472cf","names":["packages/interaction/user-approval/src/index.ts#ApprovalPolicy"],"sources":["packages/interaction/user-approval/src/index.ts:68"]},
|
||||
{"digest":"062f94b162cfd50fccd0733645d262b6fce404349cd003b71d15daf5539f0c4e","names":[],"sources":[]},
|
||||
{"digest":"0657d3ca5ac4e7bf79b00bcf860b1c7159d01a9da137497221ec2f1776e365f7","names":["packages/core/session/src/types.ts#SessionSeq"],"sources":["packages/core/session/src/types.ts:33"]},
|
||||
{"digest":"07e1a58c58b593f507b8e7f25723bb0aa42baae6e00f5ecfddf28cb1e64d3235","names":["packages/session/session-title/src/types.ts#SessionTitleModelIdentity","packages/subagent/tool-subagent/src/model-selection.ts#AllowedModelRoute"],"sources":["packages/session/session-title/src/types.ts:20","packages/subagent/tool-subagent/src/model-selection.ts:9"]},
|
||||
@@ -105,7 +105,7 @@
|
||||
{"digest":"10e386801903f9d424602c31d3ff837a94aef623c8b391376d8d9e253525fabe","names":[],"sources":[]},
|
||||
{"digest":"11638dc58a31496fb6706ced3e23e3a2db1f3b2a8845968c810827a72b5fc811","names":[],"sources":[]},
|
||||
{"digest":"119abe2b9c0b2f46ecb31d82b9acba4d4b30e049f655e2475e8b4ae12d5a9e25","names":[],"sources":["packages/experimental/hook-protocol/src/types.ts:19"]},
|
||||
{"digest":"11f70b595aa76897fc4e25a352bfa718c659f44b799e5aa14e84e5a38fe72912","names":[],"sources":["packages/interaction/user-approval/src/index.ts:40"]},
|
||||
{"digest":"11f70b595aa76897fc4e25a352bfa718c659f44b799e5aa14e84e5a38fe72912","names":[],"sources":["packages/interaction/user-approval/src/index.ts:41"]},
|
||||
{"digest":"124aa62078ca3311353b39cc3bf6c75da281338be6bbf672f8b361d828cddca3","names":[],"sources":[]},
|
||||
{"digest":"12a0cfa619ac4a5fc6c3e8e359c8dae15769edc61497a575935c78d5842f8e42","names":[],"sources":[]},
|
||||
{"digest":"12d5808fac1996b9b3c2afa806e03022252ba2d7b88f835862ae1896591f60c7","names":["packages/llm/llm/src/types.ts#ImageBlock"],"sources":["packages/llm/llm/src/types.ts:79"]},
|
||||
|
||||
@@ -272,7 +272,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.
|
||||
|
||||
@@ -272,7 +272,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.2/approval-projection-dependency/guide.md
|
||||
/:
|
||||
en: 10a5bce762cfa31b
|
||||
zh: eddf92b54eb31683
|
||||
/approval-requires-the-projection-registry:
|
||||
en: 89b130eab1f61761
|
||||
zh: f1382f60b6f8612f
|
||||
/approval-requires-the-projection-registry/change:
|
||||
en: a338aa2a5279676c
|
||||
zh: 8744d85c90437bfd
|
||||
/approval-requires-the-projection-registry/migration:
|
||||
en: fd59212cd75f01a2
|
||||
zh: a36f9f6c5101d733
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "Custom ApprovalService compositions must provide sessionProjections and await service activation."
|
||||
---
|
||||
|
||||
# Approval requires the Projection registry
|
||||
|
||||
English | [中文](guide.zh.md)
|
||||
|
||||
## Change
|
||||
|
||||
`ApprovalService` requires `sessionProjections`. It waits for projection registration during activation instead of reading the event log when the registry is absent or pending. Custom compositions that mounted approval alone must add the registry. Shipped profiles already provide it.
|
||||
|
||||
Approval outcomes, audit events, policy defaults, and synchronous business methods keep their existing behavior. Session logs and checkpoint formats do not change. The private registration handle is process-local; checkpoints contain only the policy override and turn state.
|
||||
|
||||
## Migration
|
||||
|
||||
1. In custom `cordis.yml` files, mount `@deepseek-ai/dsh-session-projection` before `@deepseek-ai/dsh-user-approval`. Avoid adding a duplicate registry to a shipped profile.
|
||||
2. In direct host compositions, use `ctx.plugin(ApprovalService)` and await activation before using `ctx.approval`. Do not construct the service with `new ApprovalService(...)`: that does not run `Service.init`.
|
||||
|
||||
```ts
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
```
|
||||
|
||||
3. Let the host and registry complete Session recovery before exposing historical state to synchronous readers. Follow the [registry lifecycle](../../../../packages/session/session-projection/README.md); do not add history loading to approval getters or replay runtime hooks.
|
||||
4. Confirm activation completes and the existing approval tests still produce the same outcomes and audit records. Registration errors must fail activation instead of selecting a full-log fallback.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
kind: upgrade-guide
|
||||
description: "自定义 ApprovalService 组合必须提供 sessionProjections 并等待服务激活。"
|
||||
---
|
||||
|
||||
# 审批服务要求 Projection 注册表
|
||||
|
||||
[English](guide.md) | 中文
|
||||
|
||||
## 变更
|
||||
|
||||
`ApprovalService` 要求 `sessionProjections`。它在激活时等待 Projection 注册,不再在注册表缺失或尚未就绪时读取事件日志。原先只挂载审批服务的自定义组合必须补上注册表。发布的配置已提供该服务。
|
||||
|
||||
审批结果、审计事件、策略默认值和同步业务方法保持原有行为。会话日志和检查点格式不变。私有注册句柄仅在进程内有效;检查点只保存策略覆盖值和轮次状态。
|
||||
|
||||
## 迁移
|
||||
|
||||
1. 在自定义 `cordis.yml` 中,先挂载 `@deepseek-ai/dsh-session-projection`,再挂载 `@deepseek-ai/dsh-user-approval`。不要向发布的配置重复添加注册表。
|
||||
2. 在直接挂载服务的宿主中,使用 `ctx.plugin(ApprovalService)` 并等待激活完成,再使用 `ctx.approval`。不要用 `new ApprovalService(...)` 直接构造服务;这不会执行 `Service.init`。
|
||||
|
||||
```ts
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import ApprovalService from '@deepseek-ai/dsh-user-approval'
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(ApprovalService)
|
||||
```
|
||||
|
||||
3. 由宿主和注册表完成会话恢复,再向同步读取方交付历史状态。遵循[注册表生命周期](../../../../packages/session/session-projection/README.zh.md);不要在审批 getter 中加载历史,也不要在回放时触发运行时钩子。
|
||||
4. 确认激活完成,原有审批测试仍产生相同的结果和审计记录。注册错误必须使激活失败,不能切换到全日志回退路径。
|
||||
@@ -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: { 'native@1.0.0': 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/sidechat@0.2.0" 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://git@example.com/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:token@example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
'@scope/sidechat@git+http://token@example.com/sidechat.git#244b67419a80844f5fb1fd7971109b9c19a20b76',
|
||||
'@scope/sidechat@git+ssh://git:secret@example.com/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://git@git.example/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://git@git.example/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://git@git.example/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://git@git.example/team/sub/plugin.git#semver:<2 >1')
|
||||
expect(await choose('git+http://git.example/team/sub/plugin.git')).toBe('git+ssh://git@git.example/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://git@git.example/\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://git@git.example/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://git@git.example/team/plugin.git',
|
||||
'git@git.example:team/plugin.git',
|
||||
'git+https://person:token@git.example/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://git@git.example/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://git@git.example/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://git@git.example/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', 'user.email=fixture@example.invalid', '-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://git@git.example/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' })
|
||||
|
||||
@@ -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: 1d70d520d4a7537c
|
||||
zh: 83febffaf90e0ac5
|
||||
en: 6ceacacf8770eec9
|
||||
zh: 4864c23171b99a82
|
||||
/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/lexical@0.49.0.patch) 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/lexical@0.49.0.patch)让光标滚动计算与 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'
|
||||
@@ -178,13 +178,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])
|
||||
|
||||
@@ -275,6 +273,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
|
||||
@@ -290,7 +293,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.
|
||||
@@ -400,6 +403,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'
|
||||
@@ -1468,6 +1469,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.
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ DeepSeek 账号和 API Key 路由显示为独立提供方分组,各自展示
|
||||
|
||||
未选择模型或已保存的选择不在目录中时,composer 以常规字重显示“请选择模型”,并隐藏推理强度文案。点击后直接打开模型列表,Escape 和 Shift+Tab 关闭列表。目录为空时显示“暂无可用模型”
|
||||
|
||||
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时若显示模型搜索框则聚焦搜索框,否则聚焦当前模型或推理强度行,返回则落在打开该面板的格子上。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。
|
||||
与 `ui-conversation` 及命令包一起挂载本插件;composer 随即在待处理指示器旁显示模型位,`/model` 则以弹窗打开同一份目录。模型位菜单打开期间,`↑`/`↓` 在根菜单和推理等级行间移动焦点,或在模型列表中移动高亮并让焦点保留在搜索框。Enter 和 Tab 选定高亮模型或聚焦行;Escape 与 `Shift+Tab` 先退出已下钻的面板,否则关闭并回到触发器。下钻时,桌面设备在显示模型搜索框时聚焦搜索框;其他情况聚焦当前模型或推理强度行,没有选中项时聚焦首个可用行。返回时聚焦打开该面板的格子。手机和平板在用户主动点按搜索框或使用键盘导航前,不会聚焦搜索框。所选模型可用时,composer 显示目录中的模型名称。模型或提供方被删除时(包括账号退登),保留已保存的提供方、模型和推理强度,但不展示其 ID。目录条目恢复后重新显示名称和推理强度。
|
||||
|
||||
鼠标选择沿用浏览器原生点击及其取消行为;仅按下按钮不会选定。打开根菜单时聚焦触发按钮,再次点击触发按钮会关闭菜单并把焦点还给它。根菜单行和搜索清除按钮的键盘焦点与悬停使用相同底色,不显示原生焦点轮廓。选定模型或推理等级后,焦点返回触发按钮但不显示焦点环;焦点离开按钮或重新打开菜单后恢复正常焦点提示。等待任一入口发起的选择结果时,焦点停在触发按钮,触发按钮以加载图标代替下拉箭头,该选择包含的值所在行以加载图标代替勾选标记;选择被拒绝后菜单保持打开。显示模型搜索框时,Tab 返回搜索框并高亮当前模型,否则返回当前选中行。
|
||||
|
||||
### 模型与推理强度
|
||||
|
||||
按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录聚焦当前模型或首行,直接支持键盘导航。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
|
||||
按钮模型菜单仅在完整目录超过四个模型时显示搜索框;较小目录直接支持键盘导航,并在所有设备上聚焦当前模型或首个可用行。`/model` 命令始终保留搜索框。两个搜索框均按模型名称进行不区分大小写的匹配,支持按顺序输入不连续字符,并忽略首尾空格。每个提供方内的结果优先按前缀匹配、其次按匹配得分、最后按目录顺序排列。没有匹配项的分组会隐藏。空目录和无匹配结果均通过状态区域通知屏幕阅读器。方向键在各提供方分组的结果间循环移动高亮,焦点保留在搜索框;Enter 或 Tab 选定高亮项。左右方向键仍用于移动输入光标。打开时高亮当前模型或首个可用选项,修改搜索内容后高亮首个结果。重新打开模型面板会清空搜索内容。
|
||||
|
||||
composer 菜单中的模型名称和推理等级均使用 400(regular)字重,包括选中项。搜索框沿用 command 弹窗的紧凑样式,在浅/深色主题下均保持背景和边框透明,不显示前置图标,占位文字使用 caption 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。
|
||||
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* search field. Provider headings paint their background only while pinned
|
||||
* by scrolling. Clearing a query restores the full list and search focus.
|
||||
* Selecting restores trigger focus without a ring until the trigger loses focus
|
||||
* or the menu reopens. Model names match a case-insensitive ordered subsequence
|
||||
* or the menu reopens. Mobile devices restore button focus when opening or
|
||||
* drilling; model search takes focus only after explicit user input.
|
||||
* Model names match a case-insensitive ordered subsequence
|
||||
* within each provider group, ranked by
|
||||
* prefix, alignment score, then catalog order. Returning to the root pane
|
||||
* hands focus back to the cell that opened it. Data and submission ride the
|
||||
@@ -35,7 +37,7 @@ import clsx from 'clsx'
|
||||
import type { ModelReasoningEffort, ModelSelection } from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import {
|
||||
IconCheckOutlineRegular, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseFillRegular,
|
||||
IconDataOutlineRegular, IconWarningOutlineRegular, Input, rankByName, StateDot, Toast,
|
||||
IconDataOutlineRegular, IconWarningOutlineRegular, Input, isMobileDevice, rankByName, StateDot, Toast,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ModelLabelOwner, ModelSelectInjected } from './slots.ts'
|
||||
@@ -184,7 +186,7 @@ export function ModelSelect(
|
||||
paneFocus.current = null
|
||||
if (!open || intent === null) return
|
||||
if (intent === 'drill') {
|
||||
if (pane === 'model' && showSearch) {
|
||||
if (pane === 'model' && showSearch && !isMobileDevice()) {
|
||||
searchRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { RemoteError, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
@@ -457,6 +458,71 @@ describe('ModelSelect keyboard walk', () => {
|
||||
return select
|
||||
}
|
||||
|
||||
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', maxTouchPoints: 5 },
|
||||
{ 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', maxTouchPoints: 5 },
|
||||
].flatMap(device => [1, 5].map(modelCount => ({ ...device, modelCount }))))(
|
||||
'retains menu keyboard focus on $device with $modelCount models without automatically focusing search',
|
||||
async ({ userAgent, platform, maxTouchPoints, modelCount }) => {
|
||||
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 select = vi.fn()
|
||||
render(<ModelSelect renderSlot={renderLabel} locked={false} available
|
||||
directory={createSnapshotStore(state({ groups: modelGroups(modelCount) }))}
|
||||
load={vi.fn()} select={select} t={t}
|
||||
/>)
|
||||
const trigger = screen.getByRole('button', { name: /选择模型/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
const modelCell = screen.getByRole('menuitem', { name: /^模型/ })
|
||||
const effortCell = screen.getByRole('menuitem', { name: /推理等级/ })
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(modelCell, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(effortCell)
|
||||
fireEvent.keyDown(effortCell, { key: 'Tab' })
|
||||
const high = screen.getByRole('menuitemradio', { name: 'High' })
|
||||
const max = screen.getByRole('menuitemradio', { name: /Max/ })
|
||||
expect(document.activeElement).toBe(high)
|
||||
fireEvent.keyDown(high, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(max)
|
||||
fireEvent.keyDown(max, { key: 'Escape' })
|
||||
|
||||
const restoredEffortCell = screen.getByRole('menuitem', { name: /推理等级/ })
|
||||
expect(document.activeElement).toBe(restoredEffortCell)
|
||||
fireEvent.keyDown(restoredEffortCell, { key: 'ArrowUp' })
|
||||
const restoredModelCell = screen.getByRole('menuitem', { name: /^模型/ })
|
||||
expect(document.activeElement).toBe(restoredModelCell)
|
||||
fireEvent.keyDown(restoredModelCell, { key: 'Tab' })
|
||||
const model = screen.getByRole('menuitemradio', { name: 'DeepSeek-V4-Flash' })
|
||||
expect(document.activeElement).toBe(model)
|
||||
if (modelCount > 4) {
|
||||
const search = screen.getByRole('searchbox')
|
||||
expect(document.activeElement).not.toBe(search)
|
||||
fireEvent.keyDown(model, { key: 'ArrowDown' })
|
||||
expect(document.activeElement).toBe(search)
|
||||
} else {
|
||||
expect(screen.queryByRole('searchbox')).toBeNull()
|
||||
}
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(document.activeElement).toBe(screen.getByRole('menuitem', { name: /^模型/ }))
|
||||
fireEvent.keyDown(document.activeElement!, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
await waitFor(() => { expect(document.activeElement).toBe(trigger) })
|
||||
expect(select).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['model', 'effort'])('prevents button mousedown defaults in the %s pane without selecting it', (pane) => {
|
||||
const select = mountOpen()
|
||||
const cell = screen.getByRole('menuitem', { name: pane === 'model' ? /^模型/ : /推理等级/ })
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package:
|
||||
en: fd8e3ad7cda08270
|
||||
zh: cce22df78619f63d
|
||||
en: 216dcea8d6903007
|
||||
zh: 160888842501694e
|
||||
/deepseek-ai-dsh-client-ui-plugin-manager/use-this-package/installing-a-bundle:
|
||||
en: fcb2e69c8b028c0a
|
||||
zh: d505a075679974eb
|
||||
|
||||
@@ -31,7 +31,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
|
||||
|
||||
Select **Plugins** in the sidebar. The information button uses an 11px caption-colored icon in a 20px square hover area, with a 4px gap after the subtitle. It explains that this page configures official plugins and installs or manages other plugins, while **Settings → Built-in plugins** shows the built-in inventory and runtime status. Hover or focus shows the shared tooltip; clicking keeps it open until another click, Escape, Tab, or an outside click. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Built-in plugin settings** lists official configuration pages by their configured order: Subagent, web search, agent loop, then shell. **Experimental plugins** shows the primary optional bundles, with **More** beside its heading opening a separate page of the same title. That page lists the official bundles eligible for display, including those on the main list, with Codex and Claude Code subagents last. Its back button returns to the main list, where **Installed plugins** remains below Experimental plugins. Bundle details return to the list they were opened from. Catalog entries keep their position after installation or removal. **Installed plugins** lists other profile bundles. Installation-carried optional bundles have no uninstall action. Experimental packages retain the **Experimental** marker on both lists. Switching on a bundle that page holds back adds it to the main list in the position that page shows it, and switching it off leaves it on that page alone unless its manifest hides unselected bundles; other bundles keep their place on both. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Built-in plugins** section.
|
||||
|
||||
The heading moves its complete action group to another line when space is insufficient. Action labels stay on one line, and the information button follows the subtitle text. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field.
|
||||
As available header width decreases, Refresh and Add plugin first become icon-only controls with accessible names and tooltips; the tabs then shrink from their usual minimum width to fit their labels, and the action group wraps only when necessary. The information button follows the subtitle text. The main page header and list share one scroll container. Header controls move with the scroll position until they reach their compact positions; scrolling back follows the same coordinates. The header retains its resting layout height, so moving its controls does not change the list's scroll range. A barely scrolling list can stop before the header is fully compact. The More list's back-and-title row stays fixed, and detail pages scroll their head row with the content. The Installed/Discover tabs use the compact 34px control with a 2px inset around the selection indicator. The subtitle scrolls out of view; with tabs present, the title also scrolls out, and the tabs settle beside Refresh and Add plugin where space permits. On narrow pages, the tabs and icon actions share a row beneath the heading where they fit; otherwise the tabs stay above the actions. Their relative order stays the same throughout scrolling. The resting and compact layouts retain their 28px and 16px page-top insets respectively, with 16px below the compact controls. macOS adds the frame's titlebar clearance while resting; its compact bar keeps 16px above and below with the sidebar expanded, and with the sidebar collapsed it also clears the frame's top strip so the controls stay below the native window controls. Only on macOS, when the sidebar is collapsed, the compact controls keep 8px rather than 16px above them in addition to the unchanged frame clearance, at every width. The resting layout and compact bottom spacing stay unchanged. Windows relies on the frame's caption offset without adding it again. The divider spans the page at the bottom of the compact header; the list's initial top spacing scrolls with its content. Hover styling applies only where the pointer can hover, so a tap leaves no card, row, or menu option in its hover state; cards and icon actions answer a touch-down with the pressed fill instead. The install, registry, and filter fields take a 16px face under a coarse pointer, which keeps iOS Safari from zooming a focused field.
|
||||
|
||||
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` of a package-root plugin; subpath plugins never read one; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the package root's manifest `icon` or exported `./icon`, or a subpath plugin's exported `<subpath>/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ kind: "package-reference"
|
||||
|
||||
在侧栏选择**插件**。信息按钮使用 11px 的 caption 色图标和 20px 方形悬停区域,与副标题间隔 4px。它说明当前页用于配置官方插件、安装和管理其他插件,**设置 → 内置插件**则展示内置插件列表及运行状态。悬停或聚焦显示共享 tooltip;点击后保持展开,直至再次点击、Escape、Tab 或外部点击。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**内置插件配置**按配置的顺序列出官方配置页:子智能体、网页搜索、Agent 循环、终端。**实验性插件**展示主要的可选组合包,标题旁的**更多**打开同名的独立页面。该页列出符合展示条件的官方组合包,也包含主列表已有的条目,Codex 和 Claude Code 子智能体排在最后。返回按钮回到主列表,**已安装插件**保持在实验性插件下方。组合包详情返回打开它的列表。目录条目在安装或移除后保持原位置。**已安装插件**列出其他 profile 组合包。安装随附的可选组合包没有卸载操作。实验性包在两个列表中均保留**实验性**标签。开启仅在该页列出的组合包会按该页中的位置把它加入主列表,关闭后它只留在该页,但 manifest 声明默认隐藏的组合包会从该页消失;其他组合包在两个列表中均保持原位。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**内置插件**分区中编辑。
|
||||
|
||||
空间不足时,页头将整个操作组移到下一行。操作文案保持单行,信息按钮紧随副标题文字。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。
|
||||
页头可用宽度缩小时,“刷新”和“添加插件”先变为保留可访问名称与 tooltip 的图标按钮;标签再从常规最小宽度收紧为文案所需宽度,仍不足时操作组才换行。信息按钮紧随副标题文字。主页面的页头和列表共用一个滚动容器。页头控件随滚动位置移动,到达紧凑态位置后停住;反向滚动沿同样的坐标返回。页头保留静止态的布局高度,因此控件移动不会改变列表的滚动范围。只能滚动少量距离的列表可能在页头完全收拢前到达底部。“更多”列表的返回与标题行固定,详情页的页头随内容滚动。“已安装/发现”标签使用总高 34px 的紧凑控件,选中指示块与外框之间留 2px。副标题随滚动离开视口;有标签时标题也随之滚出,标签在空间足够时停靠到“刷新”“添加插件”旁边。窄页面中,标签与图标操作优先在标题下方共用一行;放不下时,标签始终在操作组上方。滚动过程中两组控件的相对顺序保持不变。静止态与紧凑态分别保留 28px 和 16px 的页面顶部留白,紧凑态控件下方保留 16px。macOS 静止态保留框架的标题栏安全区;折叠态在侧栏展开时上下各留 16px,侧栏收起时再避让框架顶部安全区,使控件位于原生窗口控件下方。仅在 macOS 侧栏收起时,紧凑态控件在原有框架安全区之外的顶部留白统一由 16px 减为 8px,不受窗口宽度影响;静止态布局与紧凑态底部留白均不变。Windows 使用框架已有的标题栏偏移,不重复叠加。分隔线在紧凑页头底部横跨页面,列表初始的顶部留白随内容滚动。悬停样式只在可悬停的指针下生效,点按不会让卡片、行或菜单项停留在悬停态;卡片与图标操作改以按下填充回馈触摸。安装、注册表与筛选输入框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。
|
||||
|
||||
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到包根插件可访问的 `package.json`,子路径插件从不读取;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示包根的清单 `icon` 或导出的 `./icon`,或子路径插件导出的 `<子路径>/icon`;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Plugin manager page: a centred column with its header, notices, and the package cards.
|
||||
/* Plugin manager page: the main header and content share one scroller; the header retains its flow height.
|
||||
The More list has a separate fixed head, and detail views scroll with their content.
|
||||
|
||||
Hover affordances sit under @media (hover: hover): a tap latches :hover, which
|
||||
would strand a card, row, or control in its pointer-only state. Text fields take
|
||||
@@ -6,27 +7,144 @@
|
||||
for a focused field under 16px and leaves the page pannable afterwards. */
|
||||
|
||||
.page {
|
||||
--plugin-page-inset: clamp(24px, 4vw, 48px);
|
||||
/* Every head row's top inset; the collapsed page bar narrows it. */
|
||||
--plugin-page-top-inset: 28px;
|
||||
--bundle-progress-size: 14px;
|
||||
--bundle-toggle-gap: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
/* Inline inset only: the 28px top inset belongs to the head row
|
||||
(`.pageHead`, `.detailTop`, `.moreHead`). macOS window dragging is owned by a row's own box,
|
||||
so an inset above that box leaves a top strip no drag region covers. */
|
||||
padding: 0 var(--plugin-page-inset);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.page > * {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
.pageBody > .pageBar {
|
||||
container: plugin-page-bar / inline-size;
|
||||
align-self: flex-start;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: calc(100% + 2 * var(--plugin-page-inset) + var(--plugin-page-scrollbar-width, 0px));
|
||||
max-width: none;
|
||||
margin-inline: calc(-1 * var(--plugin-page-inset));
|
||||
box-sizing: border-box;
|
||||
padding-inline: var(--plugin-page-inset);
|
||||
}
|
||||
|
||||
.pageBar > .pageHead {
|
||||
align-self: center;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
/* Spans the page's full width, so its scrollbar sits at the page edge, and re-centres the column inside. */
|
||||
.page > .pageBody {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
width: calc(100% + 2 * var(--plugin-page-inset));
|
||||
max-width: none;
|
||||
margin-inline: calc(-1 * var(--plugin-page-inset));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 32px;
|
||||
box-sizing: border-box;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
/* Inline and bottom insets only: the 28px top inset belongs to the head row
|
||||
(`.pageHead`, `.detailTop`). macOS window dragging is owned by a row's own box,
|
||||
so an inset above that box leaves a top strip no drag region covers. */
|
||||
padding: 0 clamp(24px, 4vw, 48px) 48px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
padding: 0 var(--plugin-page-inset) 48px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
scroll-padding-top: var(--plugin-header-scroll-padding, 0px);
|
||||
}
|
||||
|
||||
.page > * {
|
||||
/* The initial list inset scrolls away; the scroll viewport meets the fixed bar directly. */
|
||||
.page > :not(.pageBody) + .pageBody {
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
.pageBody > * {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
max-width: 960px;
|
||||
}
|
||||
|
||||
/* Endpoint copies never enter the scroll layout or the accessibility tree. */
|
||||
.pageBody > .pageBar[data-measuring] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
margin: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pageBody > .pageBar[data-natural] {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pageBar[data-natural] .pageHead {
|
||||
position: relative;
|
||||
height: var(--plugin-header-rest-height);
|
||||
clip-path: inset(0 0 max(0px, calc(var(--plugin-header-rest-height) - var(--plugin-header-paint-height))) 0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pageBar[data-natural] .pageHeading {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.pageBar[data-natural] [data-plugin-header-part] {
|
||||
position: absolute;
|
||||
left: var(--plugin-part-x);
|
||||
top: var(--plugin-part-y);
|
||||
width: var(--plugin-part-width);
|
||||
margin: 0;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pageBar[data-natural] .pageTabs {
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pageBar[data-natural] .pageTabList {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.pageBar[data-natural]::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 0 auto;
|
||||
height: var(--plugin-header-paint-height, var(--plugin-header-rest-height));
|
||||
background: var(--dsw-alias-bg-base);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pageBar[data-natural][data-compact]::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
top: 0;
|
||||
height: var(--plugin-header-compact-height);
|
||||
box-sizing: border-box;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
}
|
||||
|
||||
.pageHead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -36,7 +154,7 @@
|
||||
box-sizing: border-box;
|
||||
/* The page's former top inset: the row starts at the window's top edge, so the whole
|
||||
strip above the page content drags the window. */
|
||||
padding-top: 28px;
|
||||
padding-top: var(--plugin-page-top-inset);
|
||||
}
|
||||
|
||||
/* macOS hiddenInset titlebar: the frame's top clearance keeps the head below
|
||||
@@ -44,12 +162,13 @@
|
||||
owns the window drag over its own box — clearance included — so this page's
|
||||
top strip drags without a frame band. */
|
||||
:global([data-platform='darwin']) .pageHead {
|
||||
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
|
||||
padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px));
|
||||
}
|
||||
|
||||
.pageHeading {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.pageTitle {
|
||||
@@ -86,6 +205,7 @@
|
||||
gap: 8px 16px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.addMenu {
|
||||
@@ -1263,9 +1383,9 @@ label.registryOption {
|
||||
.detailTop,
|
||||
.moreHead {
|
||||
display: flex;
|
||||
/* The same 28px top inset `.pageHead` carries, so the row starts at the window's
|
||||
/* The same top inset `.pageHead` carries, so the row starts at the window's
|
||||
top edge and the strip above the detail content drags the window. */
|
||||
padding-top: 28px;
|
||||
padding-top: var(--plugin-page-top-inset);
|
||||
}
|
||||
|
||||
.detailTop {
|
||||
@@ -1274,7 +1394,7 @@ label.registryOption {
|
||||
|
||||
:global([data-platform='darwin']) .detailTop,
|
||||
:global([data-platform='darwin']) .moreHead {
|
||||
padding-top: calc(28px + var(--dsh-frame-top-clearance, 0px));
|
||||
padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px));
|
||||
}
|
||||
|
||||
.crumb {
|
||||
@@ -1565,11 +1685,64 @@ label.registryOption {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Installed and contributed tabs under the page header, aligned with the title; the negative margin halves
|
||||
the page's 32px row gap, so the tabs read as part of the header. */
|
||||
/* Installed and contributed tabs under the page header at rest; the scrolled bar
|
||||
pulls them onto the actions' row. */
|
||||
.pageTabs {
|
||||
display: flex;
|
||||
margin-top: -16px;
|
||||
order: 3;
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
/* Keep spacing inside the compact bar, above its divider rather than outside the scroll viewport. */
|
||||
.pageBar[data-compact]:not([data-natural]) {
|
||||
--plugin-page-top-inset: 16px;
|
||||
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
/* With the sidebar open, native window controls sit in the sidebar rather than above the page. */
|
||||
:global([data-platform='darwin']) .pageBar[data-compact]:not([data-natural]) .pageHead {
|
||||
padding-top: var(--plugin-page-top-inset);
|
||||
}
|
||||
|
||||
/* Collapsing the sidebar moves those controls above the page; the compact bar then clears them. */
|
||||
:global([data-platform='darwin']) :global([data-sidebar-collapsed]) .pageBar[data-compact]:not([data-natural]) .pageHead {
|
||||
--plugin-page-top-inset: 8px;
|
||||
|
||||
padding-top: calc(var(--plugin-page-top-inset) + var(--dsh-frame-top-clearance, 0px));
|
||||
}
|
||||
|
||||
/* The collapsed bar's divider spans the page like the scrolled list and its scrollbar. */
|
||||
.pageBar[data-compact]:not([data-natural])::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
bottom: 0;
|
||||
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pageBar[data-compact]:not([data-natural]) .pageHead {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pageBar[data-compact]:not([data-natural]) .pageIntro {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Visually hidden rather than removed, so the page keeps its only heading for assistive technology. */
|
||||
.pageBarWithTabs[data-compact]:not([data-natural]) .pageHeading {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pageBarWithTabs[data-compact]:not([data-natural]) .pageTabs {
|
||||
order: 1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pageTabList {
|
||||
@@ -1577,6 +1750,56 @@ label.registryOption {
|
||||
min-width: 240px;
|
||||
}
|
||||
|
||||
@container plugin-page-bar (width < 600px) {
|
||||
.pageBarWithTabs .pageHead {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pageBarWithTabs .pageHeading {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
.pageBarWithTabs .pageTabs {
|
||||
order: 2;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.pageBarWithTabs .toolbar {
|
||||
order: 3;
|
||||
}
|
||||
|
||||
.toolbarLabel {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.pageHead .addButton,
|
||||
.pageHead .addPrimary {
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
/* The icon actions keep their width; a wrapped update note stays within the head. */
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.updatesNote {
|
||||
order: 3;
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the usual tab width until the icon actions and their gaps need its space. */
|
||||
@container plugin-page-bar (width < 372px) {
|
||||
.pageTabList {
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* The Installed groups keep the page's row gap inside their tab panel. */
|
||||
.installedPanel {
|
||||
display: flex;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { createNavigationStore } from './navigation-store.ts'
|
||||
import type { PluginHeaderLayoutFace } from './header-scroll.ts'
|
||||
import { rowConfigKey, type OfficialItem } from './config-ledger.ts'
|
||||
import { INSTALL_GIT_EXAMPLE, INSTALL_PATH_EXAMPLE, type PluginManagerLocaleKey } from './locales.ts'
|
||||
import {
|
||||
@@ -45,7 +46,7 @@ export type PluginManagerPageProps =
|
||||
| 'plugins.add.actions' | 'plugins.tab'
|
||||
| 'plugins.detail.actions' | 'plugins.detail.badge' | 'plugins.detail.section'
|
||||
>
|
||||
& InjectFace<PluginManagerFace>
|
||||
& InjectFace<PluginManagerFace & PluginHeaderLayoutFace>
|
||||
& PropsStore<ReturnType<typeof createNavigationStore>>
|
||||
|
||||
/** The page's slot renderer, narrowed to the configuration slots. */
|
||||
@@ -65,10 +66,12 @@ function AddPluginMenu({ t, disabled, openInstall, renderSlot }: {
|
||||
const onDismiss = (): void => { setOpen(false) }
|
||||
return (
|
||||
<span className={css.addGroup} role="group" aria-label={t('addPlugin')}>
|
||||
<Button variant="primary" size="sm" className={css.addPrimary} icon={<IconPlusOutlineRegular size={13} />}
|
||||
disabled={disabled} onClick={() => { onDismiss(); openInstall() }}>
|
||||
{t('addPlugin')}
|
||||
</Button>
|
||||
<Tooltip label={t('addPlugin')} side="bottom" delayMs={500} focusDelayMs={500} portal disabled={disabled}>
|
||||
<Button variant="primary" size="sm" className={css.addPrimary} icon={<IconPlusOutlineRegular size={13} />}
|
||||
aria-label={t('addPlugin')} disabled={disabled} onClick={() => { onDismiss(); openInstall() }}>
|
||||
<span className={css.toolbarLabel}>{t('addPlugin')}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Menu open={open} onClose={onDismiss} align="end" portal autoFocus listClassName={css.addMenu}
|
||||
anchor={(
|
||||
<Button variant="primary" size="sm" className={css.addMore}
|
||||
@@ -1552,6 +1555,7 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
useEffect(() => { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab])
|
||||
const tabsId = useId()
|
||||
const [activation, setActivation] = useState<string | null>(null)
|
||||
const [headerRef] = useState(props.createHeaderRef)
|
||||
useEffect(() => { ensure() }, [ensure])
|
||||
// Installation highlights visible cards without changing the user's current page.
|
||||
const { highlight, clearHighlight } = { highlight: state.highlight, clearHighlight: props.clearHighlight }
|
||||
@@ -1646,64 +1650,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
|
||||
return (
|
||||
<section className={css.page} data-plugin-panel aria-busy={state.status === 'loading' || refreshing}>
|
||||
{showsMainList
|
||||
? (
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
<div className={css.pageHeading}>
|
||||
<h1 className={css.pageTitle}>{t('title')}</h1>
|
||||
<div className={css.pageIntro}>
|
||||
<span>{t('intro')}</span>
|
||||
<Tooltip label={t('infoDescription')} side="bottom" delayMs={300} maxWidth={300} portal openOnClick>
|
||||
<Button variant="ghost" size="sm" className={css.infoButton} aria-label={t('infoLabel')}>
|
||||
<IconInfoOutlineRegular size={11} aria-hidden="true" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className={css.toolbar} hidden={!onInstalled}>
|
||||
{updates === undefined ? null : <span className={css.updatesNote} data-tone={updates.tone}>{updates.text}</span>}
|
||||
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
|
||||
<Button variant="outline" size="sm" className={css.addButton} aria-label={t('refresh')} aria-busy={refreshing}
|
||||
disabled={!loaded || refreshing} onClick={props.refresh}
|
||||
icon={refreshing ? <StateDot state="ongoing" size={13} /> : <IconRefreshOutlineRegular size={13} />}>
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{state.install.requestId === undefined
|
||||
? <AddPluginMenu t={t} disabled={!loaded} openInstall={props.openInstall} renderSlot={renderSlot} />
|
||||
: <Button variant="primary" size="sm" className={css.addButton} icon={<IconPlusOutlineRegular size={13} />} disabled={!loaded} onClick={props.openInstall}>
|
||||
{t('installViewTask')}
|
||||
</Button>}
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
: null}
|
||||
{showsMainList && tabs.length > 0
|
||||
? (
|
||||
<div className={css.pageTabs}>
|
||||
<SegmentedTabs
|
||||
className={css.pageTabList}
|
||||
label={t('tabsLabel')}
|
||||
value={tab}
|
||||
onChange={(next) => { setTab(next) }}
|
||||
items={[
|
||||
{ value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` },
|
||||
...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{!onInstalled
|
||||
? (
|
||||
<div role="tabpanel" id={`${tabsId}-${tab}-panel`} aria-labelledby={`${tabsId}-${tab}`} className={css.tabPanel}>
|
||||
{renderSlot('plugins.tab', {
|
||||
install: props.installFrom,
|
||||
openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) },
|
||||
}, { only: tab })}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{showsMoreList ? (
|
||||
<header className={css.moreHead} data-window-drag>
|
||||
<Button size="sm" className={css.moreBack} aria-label={t('backToList')} onClick={() => { setView({ kind: 'list' }) }}>
|
||||
@@ -1712,126 +1658,199 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode {
|
||||
<h1 className={css.pageTitle}>{t('moreTitle')}</h1>
|
||||
</header>
|
||||
) : null}
|
||||
{installedCards && !loaded ? (
|
||||
<div className={css.installedPanel} {...installedPanel}>
|
||||
{state.status === 'loading' ? <ListSkeleton label={t('loading')} /> : null}
|
||||
{state.status === 'unavailable' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="idle" />{t('unavailable')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{state.notice === null || noticeLine === null
|
||||
? null
|
||||
: (
|
||||
<Toast
|
||||
key={state.notice.seq}
|
||||
text={noticeLine}
|
||||
icon={<IconWarningOutlineRegular />}
|
||||
holdMs={toastHoldMs(noticeLine)}
|
||||
onDone={props.dismissNotice}
|
||||
/>
|
||||
)}
|
||||
{!showsCards && state.status === 'error' && !refreshing
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{onInstalled && loaded && openPkg !== undefined && openRow !== undefined
|
||||
? (
|
||||
<RowDetail
|
||||
pkg={openPkg}
|
||||
row={openRow}
|
||||
form={formFor(openRow.rowId)}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
renderSlot={renderSlot}
|
||||
onBack={() => { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{onInstalled && loaded && openPkg !== undefined && openRow === undefined
|
||||
? (
|
||||
<PackageDetail
|
||||
pkg={openPkg}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
busy={packageBusy(openPkg)}
|
||||
rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
|
||||
configured={ledger.bundles.has(openPkg.name)}
|
||||
configure={configure(openPkg)}
|
||||
renderSlot={renderSlot}
|
||||
inMore={backToMore}
|
||||
detail={(
|
||||
<BundleDetailSection name={openPkg.name} version={openPkg.version} fromNpm={openPkg.availability === 'missing'}
|
||||
language={language} details={bundleDetails} t={t}
|
||||
load={props.loadDetail} retry={props.retryDetail} image={props.detailImage} />
|
||||
)}
|
||||
onBack={() => {
|
||||
if (returnTab !== undefined) setTab(returnTab)
|
||||
setView({ kind: backToMore ? 'more' : 'list' })
|
||||
}}
|
||||
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
|
||||
onUninstall={() => { props.uninstall(openPkg.name) }}
|
||||
onUpdate={() => { props.update(openPkg.name) }}
|
||||
onSetRowEnabled={setRowEnabled}
|
||||
versions={state.versions[openPkg.name]}
|
||||
onEnsureVersions={props.ensureVersions}
|
||||
onCheckVersions={() => { props.checkVersions(openPkg.name) }}
|
||||
onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{onInstalled && loaded && openItem !== undefined
|
||||
? <ItemDetail form={formFor(openItem.id)} item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
|
||||
: null}
|
||||
{loaded && installedCards
|
||||
? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error'
|
||||
? <div className={css.installedPanel} {...installedPanel}><p className={css.empty}>{t('empty')}</p></div>
|
||||
: (
|
||||
<div className={css.installedPanel} {...installedPanel}>
|
||||
{showsMoreList ? (
|
||||
<section className={css.group} data-plugin-scope="global" data-plugin-group="more">
|
||||
<ul className={css.cards}>{more.map(packageCard)}</ul>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{renderGroup('basic', t('basicTitle'), basicCards)}
|
||||
{renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))}
|
||||
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
|
||||
</>
|
||||
)}
|
||||
{/* A failed package read trails the groups it left incomplete: right under the groups on a
|
||||
first-load failure, and after the kept cards when a refresh fails over stale data. */}
|
||||
{state.status === 'error' && !refreshing
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
<div className={css.pageBody} data-plugin-body>
|
||||
{showsMainList
|
||||
? (
|
||||
<div
|
||||
className={`${css.pageBar}${tabs.length > 0 ? ` ${css.pageBarWithTabs}` : ''}`}
|
||||
data-plugin-bar
|
||||
ref={headerRef}
|
||||
>
|
||||
<header className={css.pageHead} data-window-drag>
|
||||
<div className={css.pageHeading}>
|
||||
<h1 className={css.pageTitle} data-plugin-header-part="title">{t('title')}</h1>
|
||||
<div className={css.pageIntro} data-plugin-header-part="intro">
|
||||
<span>{t('intro')}</span>
|
||||
<Tooltip label={t('infoDescription')} side="bottom" delayMs={300} maxWidth={300} portal openOnClick>
|
||||
<Button variant="ghost" size="sm" className={css.infoButton} aria-label={t('infoLabel')}>
|
||||
<IconInfoOutlineRegular size={11} aria-hidden="true" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
<div className={css.toolbar} data-plugin-toolbar data-plugin-header-part="toolbar" hidden={!onInstalled}>
|
||||
{updates === undefined ? null : <span className={css.updatesNote} data-tone={updates.tone}>{updates.text}</span>}
|
||||
<Tooltip label={t('refresh')} delayMs={500} focusDelayMs={500} side="bottom" portal disabled={!loaded || refreshing}>
|
||||
<Button variant="outline" size="sm" className={css.addButton} aria-label={t('refresh')} aria-busy={refreshing}
|
||||
disabled={!loaded || refreshing} onClick={props.refresh}
|
||||
icon={refreshing ? <StateDot state="ongoing" size={13} /> : <IconRefreshOutlineRegular size={13} />}>
|
||||
<span className={css.toolbarLabel}>{t('refresh')}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{state.install.requestId === undefined
|
||||
? <AddPluginMenu t={t} disabled={!loaded} openInstall={props.openInstall} renderSlot={renderSlot} />
|
||||
: (
|
||||
<Tooltip label={t('installViewTask')} side="bottom" delayMs={500} focusDelayMs={500} portal disabled={!loaded}>
|
||||
<Button variant="primary" size="sm" className={css.addButton} icon={<IconPlusOutlineRegular size={13} />}
|
||||
aria-label={t('installViewTask')} disabled={!loaded} onClick={props.openInstall}>
|
||||
<span className={css.toolbarLabel}>{t('installViewTask')}</span>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
{tabs.length > 0
|
||||
? (
|
||||
<div className={css.pageTabs} data-plugin-header-part="tabs">
|
||||
<SegmentedTabs
|
||||
size="sm"
|
||||
className={css.pageTabList}
|
||||
label={t('tabsLabel')}
|
||||
value={tab}
|
||||
onChange={(next) => { setTab(next) }}
|
||||
items={[
|
||||
{ value: INSTALLED_TAB, label: t('installedTab'), id: `${tabsId}-${INSTALLED_TAB}`, panelId: `${tabsId}-${INSTALLED_TAB}-panel` },
|
||||
...tabs.map(item => ({ value: item.id, label: item.label, id: `${tabsId}-${item.id}`, panelId: `${tabsId}-${item.id}-panel` })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
|
||||
{installedCards && activated !== undefined && !state.install.open
|
||||
? renderSlot('plugins.bundle.activation', {
|
||||
packageName: activated.name,
|
||||
onDismiss: () => { setActivation(null) },
|
||||
onOpenDetails: () => {
|
||||
setActivation(null)
|
||||
setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} })
|
||||
},
|
||||
}, { entryKey: activated.name }) : null}
|
||||
: null}
|
||||
|
||||
{!onInstalled
|
||||
? (
|
||||
<div role="tabpanel" id={`${tabsId}-${tab}-panel`} aria-labelledby={`${tabsId}-${tab}`} className={css.tabPanel}>
|
||||
{renderSlot('plugins.tab', {
|
||||
install: props.installFrom,
|
||||
openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) },
|
||||
}, { only: tab })}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{installedCards && !loaded ? (
|
||||
<div className={css.installedPanel} {...installedPanel}>
|
||||
{state.status === 'loading' ? <ListSkeleton label={t('loading')} /> : null}
|
||||
{state.status === 'unavailable' ? (
|
||||
<p className={`${css.status} ${css.statusWithDot}`} role="status">
|
||||
<StateDot state="idle" />{t('unavailable')}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{state.notice === null || noticeLine === null
|
||||
? null
|
||||
: (
|
||||
<Toast
|
||||
key={state.notice.seq}
|
||||
text={noticeLine}
|
||||
icon={<IconWarningOutlineRegular />}
|
||||
holdMs={toastHoldMs(noticeLine)}
|
||||
onDone={props.dismissNotice}
|
||||
/>
|
||||
)}
|
||||
{!showsCards && state.status === 'error' && !refreshing
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{onInstalled && loaded && openPkg !== undefined && openRow !== undefined
|
||||
? (
|
||||
<RowDetail
|
||||
pkg={openPkg}
|
||||
row={openRow}
|
||||
form={formFor(openRow.rowId)}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
renderSlot={renderSlot}
|
||||
onBack={() => { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{onInstalled && loaded && openPkg !== undefined && openRow === undefined
|
||||
? (
|
||||
<PackageDetail
|
||||
pkg={openPkg}
|
||||
t={t}
|
||||
resolveText={resolveText}
|
||||
busy={packageBusy(openPkg)}
|
||||
rowBusy={row => row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))}
|
||||
configured={ledger.bundles.has(openPkg.name)}
|
||||
configure={configure(openPkg)}
|
||||
renderSlot={renderSlot}
|
||||
inMore={backToMore}
|
||||
detail={(
|
||||
<BundleDetailSection name={openPkg.name} version={openPkg.version} fromNpm={openPkg.availability === 'missing'}
|
||||
language={language} details={bundleDetails} t={t}
|
||||
load={props.loadDetail} retry={props.retryDetail} image={props.detailImage} />
|
||||
)}
|
||||
onBack={() => {
|
||||
if (returnTab !== undefined) setTab(returnTab)
|
||||
setView({ kind: backToMore ? 'more' : 'list' })
|
||||
}}
|
||||
onSetEnabled={(enabled) => { props.setEnabled(openPkg.name, enabled) }}
|
||||
onUninstall={() => { props.uninstall(openPkg.name) }}
|
||||
onUpdate={() => { props.update(openPkg.name) }}
|
||||
onSetRowEnabled={setRowEnabled}
|
||||
versions={state.versions[openPkg.name]}
|
||||
onEnsureVersions={props.ensureVersions}
|
||||
onCheckVersions={() => { props.checkVersions(openPkg.name) }}
|
||||
onUpdateVersion={(version) => { props.openUpdate(openPkg.name, version) }}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{onInstalled && loaded && openItem !== undefined
|
||||
? <ItemDetail form={formFor(openItem.id)} item={openItem} t={t} renderSlot={renderSlot} onBack={() => { setView({ kind: 'list' }) }} />
|
||||
: null}
|
||||
{loaded && installedCards
|
||||
? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error'
|
||||
? <div className={css.installedPanel} {...installedPanel}><p className={css.empty}>{t('empty')}</p></div>
|
||||
: (
|
||||
<div className={css.installedPanel} {...installedPanel}>
|
||||
{showsMoreList ? (
|
||||
<section className={css.group} data-plugin-scope="global" data-plugin-group="more">
|
||||
<ul className={css.cards}>{more.map(packageCard)}</ul>
|
||||
</section>
|
||||
) : (
|
||||
<>
|
||||
{renderGroup('basic', t('basicTitle'), basicCards)}
|
||||
{renderGroup('extensions', t('extensionsTitle'), extensions.map(packageCard))}
|
||||
{renderGroup('bundles', t('bundlesTitle'), mine.map(packageCard))}
|
||||
</>
|
||||
)}
|
||||
{/* A failed package read trails the groups it left incomplete: right under the groups on a
|
||||
first-load failure, and after the kept cards when a refresh fails over stale data. */}
|
||||
{state.status === 'error' && !refreshing
|
||||
? (
|
||||
<div className={css.failure}>
|
||||
<p className={css.statusWithDot} role="alert">
|
||||
<StateDot state="error" />{t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={props.refresh}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* Activation guidance belongs beside the Installed list that holds the enabled bundle's card. */}
|
||||
{installedCards && activated !== undefined && !state.install.open
|
||||
? renderSlot('plugins.bundle.activation', {
|
||||
packageName: activated.name,
|
||||
onDismiss: () => { setActivation(null) },
|
||||
onOpenDetails: () => {
|
||||
setActivation(null)
|
||||
setView({ kind: 'package', name: activated.name, ...showsMoreList ? { from: 'more' } : {} })
|
||||
},
|
||||
}, { entryKey: activated.name }) : null}
|
||||
</div>
|
||||
{state.update === null ? null : (
|
||||
<UpdateDialog
|
||||
update={state.update}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/** Scroll-linked header geometry keeps both responsive layouts without changing the list's flow height. */
|
||||
|
||||
/** Entry-owned measurement callbacks for the plugin page. */
|
||||
export interface PluginHeaderLayoutFace {
|
||||
/** Create one element-ref lifecycle for each mounted page header. */
|
||||
createHeaderRef: () => (element: HTMLDivElement | null) => void
|
||||
}
|
||||
|
||||
interface Box {
|
||||
x: number
|
||||
y: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
interface Part {
|
||||
element: HTMLElement
|
||||
from: Box
|
||||
to: Box
|
||||
distance: number
|
||||
clipStart?: number
|
||||
clipEnd?: number
|
||||
}
|
||||
|
||||
const PARTS = ['title', 'intro', 'toolbar', 'tabs'] as const
|
||||
|
||||
/**
|
||||
* Measure the existing resting and compact CSS layouts in an inert, out-of-flow copy.
|
||||
* Their independent flex wrapping requires pixel measurements; interpolating only positioned
|
||||
* contents leaves the real header's resting footprint and the scroll range unchanged.
|
||||
* @returns A ref callback that disposes its element observers and scroll listener on detachment.
|
||||
*/
|
||||
export function createPluginHeaderRef(): (element: HTMLDivElement | null) => void {
|
||||
let dispose: (() => void) | undefined
|
||||
return (element) => {
|
||||
dispose?.()
|
||||
dispose = element === null ? undefined : attach(element)
|
||||
}
|
||||
}
|
||||
|
||||
function attach(bar: HTMLDivElement): () => void {
|
||||
const body = bar.parentElement as HTMLElement
|
||||
const parts: Part[] = []
|
||||
let distance = 1
|
||||
let restHeight = 0
|
||||
let compactHeight = 0
|
||||
let disposed = false
|
||||
|
||||
const scroll = (): void => {
|
||||
const offset = Math.max(0, body.scrollTop)
|
||||
bar.toggleAttribute('data-compact', offset >= distance)
|
||||
bar.toggleAttribute('data-scrolled', offset > 0)
|
||||
for (const part of parts) {
|
||||
const progress = Math.min(1, offset / part.distance)
|
||||
part.element.style.translate = `${(part.to.x - part.from.x) * progress}px ${(part.to.y - part.from.y) * progress}px`
|
||||
part.element.style.width = `${part.from.width + (part.to.width - part.from.width) * progress}px`
|
||||
if (part.clipStart !== undefined && part.clipEnd !== undefined) {
|
||||
const clip = Math.max(0, Math.min(1, (offset - part.clipStart) / (part.clipEnd - part.clipStart)))
|
||||
part.element.style.clipPath = `inset(${clip * 100}% 0 0)`
|
||||
} else {
|
||||
part.element.style.removeProperty('clip-path')
|
||||
}
|
||||
if (part.element.dataset.pluginHeaderPart === 'intro') {
|
||||
part.element.inert = offset >= (part.clipEnd ?? part.distance)
|
||||
}
|
||||
}
|
||||
bar.style.setProperty('--plugin-header-paint-height', `${Math.max(compactHeight, restHeight - offset)}px`)
|
||||
}
|
||||
|
||||
const measure = (): void => {
|
||||
if (disposed) return
|
||||
body.style.setProperty('--plugin-page-scrollbar-width', `${body.offsetWidth - body.clientWidth}px`)
|
||||
const clone = bar.cloneNode(true) as HTMLDivElement
|
||||
clone.removeAttribute('data-natural')
|
||||
clone.removeAttribute('data-compact')
|
||||
clone.removeAttribute('data-scrolled')
|
||||
clone.setAttribute('data-measuring', '')
|
||||
clone.setAttribute('aria-hidden', 'true')
|
||||
clone.inert = true
|
||||
clone.style.width = `${bar.getBoundingClientRect().width}px`
|
||||
for (const element of clone.querySelectorAll<HTMLElement>('[id], [data-plugin-header-part]')) {
|
||||
element.removeAttribute('id')
|
||||
if (element.hasAttribute('data-plugin-header-part')) element.removeAttribute('style')
|
||||
}
|
||||
body.append(clone)
|
||||
const snapshot = (): { height: number; boxes: Map<string, Box> } => {
|
||||
const head = (clone.querySelector('header') as HTMLElement).getBoundingClientRect()
|
||||
const boxes = new Map<string, Box>()
|
||||
for (const name of PARTS) {
|
||||
const element = clone.querySelector<HTMLElement>(`[data-plugin-header-part="${name}"]`)
|
||||
if (element === null) continue
|
||||
const box = element.getBoundingClientRect()
|
||||
boxes.set(name, { x: box.x - head.x, y: box.y - head.y, width: box.width, height: box.height })
|
||||
}
|
||||
return { height: clone.getBoundingClientRect().height, boxes }
|
||||
}
|
||||
let resting: ReturnType<typeof snapshot>
|
||||
let compact: ReturnType<typeof snapshot>
|
||||
try {
|
||||
resting = snapshot()
|
||||
clone.setAttribute('data-compact', '')
|
||||
compact = snapshot()
|
||||
} finally {
|
||||
clone.remove()
|
||||
}
|
||||
restHeight = resting.height
|
||||
compactHeight = compact.height
|
||||
distance = Math.max(1, restHeight - compactHeight)
|
||||
parts.length = 0
|
||||
const hasTabs = resting.boxes.has('tabs')
|
||||
const compactTitle = compact.boxes.get('title') as Box
|
||||
for (const name of PARTS) {
|
||||
const from = resting.boxes.get(name)
|
||||
if (from === undefined) continue
|
||||
const element = bar.querySelector(`[data-plugin-header-part="${name}"]`) as HTMLElement
|
||||
let to = compact.boxes.get(name) as Box
|
||||
let clipStart: number | undefined
|
||||
let clipEnd: number | undefined
|
||||
if (name === 'intro' || (name === 'title' && hasTabs)) {
|
||||
to = { ...from, y: -from.height }
|
||||
if (name === 'intro' && !hasTabs) {
|
||||
clipStart = Math.max(0, from.y - compactTitle.y - compactTitle.height)
|
||||
clipEnd = clipStart + from.height
|
||||
to.y = from.y - clipEnd
|
||||
}
|
||||
}
|
||||
const travel = Math.max(1, Math.abs(to.y - from.y))
|
||||
distance = Math.max(distance, travel)
|
||||
element.style.setProperty('--plugin-part-x', `${from.x}px`)
|
||||
element.style.setProperty('--plugin-part-y', `${from.y}px`)
|
||||
element.style.setProperty('--plugin-part-width', `${from.width}px`)
|
||||
parts.push({
|
||||
element, from, to, distance: travel,
|
||||
...clipStart !== undefined && clipEnd !== undefined ? { clipStart, clipEnd } : {},
|
||||
})
|
||||
}
|
||||
bar.style.setProperty('--plugin-header-rest-height', `${restHeight}px`)
|
||||
bar.style.setProperty('--plugin-header-compact-height', `${compactHeight}px`)
|
||||
bar.style.setProperty('--plugin-header-travel', `${distance}px`)
|
||||
body.style.setProperty('--plugin-header-scroll-padding', `${compactHeight + 8}px`)
|
||||
bar.setAttribute('data-natural', '')
|
||||
scroll()
|
||||
}
|
||||
|
||||
measure()
|
||||
const resize = new ResizeObserver(measure)
|
||||
resize.observe(body)
|
||||
const mutation = new MutationObserver(measure)
|
||||
mutation.observe(bar.querySelector('header') as HTMLElement, {
|
||||
childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'],
|
||||
})
|
||||
body.addEventListener('scroll', scroll, { passive: true })
|
||||
document.fonts.addEventListener('loadingdone', measure)
|
||||
return () => {
|
||||
disposed = true
|
||||
resize.disconnect()
|
||||
mutation.disconnect()
|
||||
body.removeEventListener('scroll', scroll)
|
||||
document.fonts.removeEventListener('loadingdone', measure)
|
||||
body.style.removeProperty('--plugin-header-scroll-padding')
|
||||
body.style.removeProperty('--plugin-page-scrollbar-width')
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
// through the owning package's client-safe types subpath).
|
||||
import type {} from '@deepseek-ai/dsh-plugin-manager/types'
|
||||
import { PluginManagerPage } from './PluginManagerPage.tsx'
|
||||
import { createPluginHeaderRef } from './header-scroll.ts'
|
||||
import { PluginRefreshToast, type PluginRefreshToastFace } from './PluginRefreshToast.tsx'
|
||||
import { PluginsPanelIcon } from './PluginsPanelIcon.tsx'
|
||||
import { configLedgerSource } from './config-ledger.ts'
|
||||
@@ -133,7 +134,7 @@ export function apply(ctx: ClientContext): void {
|
||||
key: PANEL_ID,
|
||||
locale: NS,
|
||||
store,
|
||||
inject: () => face,
|
||||
inject: () => ({ ...face, createHeaderRef: createPluginHeaderRef }),
|
||||
children: {
|
||||
'plugins.add.actions': { kind: 'list', scope: 'root' },
|
||||
'plugins.tab': { kind: 'list', scope: 'root' },
|
||||
|
||||
@@ -162,6 +162,7 @@ function renderTab(
|
||||
}
|
||||
const navigation = createNavigationStore().create()
|
||||
const props: PluginManagerPageProps = {
|
||||
createHeaderRef: vi.fn(() => vi.fn()),
|
||||
useStore: bindSnapshotSelector(navigation), actions: navigation.actions,
|
||||
...standard,
|
||||
t,
|
||||
@@ -240,10 +241,13 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.queryByRole('tablist')).toBeNull()
|
||||
setTabs([{ id: 'discover', label: 'Discover' }])
|
||||
expect(screen.getByRole('tab', { name: en.installedTab }).getAttribute('aria-selected')).toBe('true')
|
||||
// The tabs stay in the pinned page head with the title; the tab panel scrolls in the page body.
|
||||
expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-plugin-group="bundles"]')).not.toBeNull()
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Discover' }))
|
||||
expect(navigation.getSnapshot().tab).toBe('discover')
|
||||
expect(screen.getByRole('tabpanel').getAttribute('aria-labelledby')).toBe(screen.getByRole('tab', { name: 'Discover' }).id)
|
||||
expect(screen.getByRole('tabpanel').closest('[data-plugin-body]')).not.toBeNull()
|
||||
expect(document.querySelector('[data-plugin-group="bundles"]')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: en.refresh })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Discover install' }))
|
||||
@@ -277,6 +281,22 @@ describe('PluginManagerPage', () => {
|
||||
expect(screen.getByRole('tabpanel', { name: 'Discover' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the header controls in the scrolling page without remounting them', () => {
|
||||
const { setTabs, props, hidePage } = renderTab({ packages: [pkg()] })
|
||||
const bar = document.querySelector('[data-plugin-bar]')
|
||||
const body = document.querySelector('[data-plugin-body]')!
|
||||
const refresh = screen.getByRole('button', { name: en.refresh })
|
||||
expect(bar?.parentElement).toBe(body)
|
||||
expect(props.createHeaderRef).toHaveBeenCalledTimes(1)
|
||||
setTabs([{ id: 'discover', label: 'Discover' }])
|
||||
expect(screen.getByRole('tablist').closest('header')?.closest('[data-plugin-bar]')).toBe(bar)
|
||||
act(() => { body.scrollTop = 160; fireEvent.scroll(body) })
|
||||
expect(screen.getByRole('button', { name: en.refresh })).toBe(refresh)
|
||||
expect(props.createHeaderRef).toHaveBeenCalledTimes(1)
|
||||
hidePage()
|
||||
expect(document.querySelector('[data-plugin-bar]')).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the Installed tab panel through loading and the More list out of the tabs, and returns a More plugin opened from Discover to Discover', () => {
|
||||
const worktree = '@deepseek-ai/dsh-experimental-tool-worktree'
|
||||
const { navigation, set } = renderTab({ status: 'loading', packages: [] }, {}, {
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Deterministic layout endpoints exercise scroll geometry without jsdom's absent layout engine. */
|
||||
import { describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { createPluginHeaderRef } from '../src/client/header-scroll.ts'
|
||||
|
||||
type PartName = 'title' | 'intro' | 'toolbar' | 'tabs'
|
||||
interface Box { x: number; y: number; width: number; height: number }
|
||||
interface Layout {
|
||||
width: number
|
||||
resting: { height: number; parts: Partial<Record<PartName, Box>> }
|
||||
compact: { height: number; parts: Partial<Record<PartName, Box>> }
|
||||
}
|
||||
|
||||
function box(x: number, y: number, width: number, height: number): Box {
|
||||
return { x, y, width, height }
|
||||
}
|
||||
|
||||
function wide(tabs = true): Layout {
|
||||
return {
|
||||
width: 960,
|
||||
resting: {
|
||||
height: tabs ? 140 : 90,
|
||||
parts: {
|
||||
title: box(0, 28, 120, 28), intro: box(0, 60, 320, 20), toolbar: box(600, 28, 220, 32),
|
||||
...tabs ? { tabs: box(0, 100, 240, 30) } : {},
|
||||
},
|
||||
},
|
||||
compact: {
|
||||
height: 64,
|
||||
parts: {
|
||||
title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0), toolbar: box(600, 16, 220, 32),
|
||||
...tabs ? { tabs: box(0, 16, 180, 30) } : {},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function narrow(): Layout {
|
||||
return {
|
||||
width: 320,
|
||||
resting: {
|
||||
height: 210,
|
||||
parts: {
|
||||
title: box(0, 28, 120, 28), intro: box(0, 60, 280, 20),
|
||||
toolbar: box(0, 90, 280, 32), tabs: box(0, 160, 280, 30),
|
||||
},
|
||||
},
|
||||
compact: {
|
||||
height: 70,
|
||||
parts: {
|
||||
title: box(0, 16, 120, 28), intro: box(0, 0, 0, 0),
|
||||
toolbar: box(200, 16, 80, 32), tabs: box(0, 16, 160, 30),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
|
||||
const fonts = new EventTarget()
|
||||
const releases: (() => void)[] = []
|
||||
onTestFinished(() => {
|
||||
for (const release of releases.reverse()) release()
|
||||
if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts')
|
||||
else Object.defineProperty(document, 'fonts', fontsDescriptor)
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
Object.defineProperty(document, 'fonts', { configurable: true, value: fonts })
|
||||
|
||||
const resizes: ControlledResize[] = []
|
||||
const mutations: ControlledMutation[] = []
|
||||
class ControlledResize implements ResizeObserver {
|
||||
observe = vi.fn<(target: Element, options?: ResizeObserverOptions) => void>()
|
||||
unobserve = vi.fn<(target: Element) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
constructor(private readonly callback: ResizeObserverCallback) { resizes.push(this) }
|
||||
deliver(): void { this.callback([], this) }
|
||||
}
|
||||
class ControlledMutation implements MutationObserver {
|
||||
observe = vi.fn<(target: Node, options?: MutationObserverInit) => void>()
|
||||
disconnect = vi.fn<() => void>()
|
||||
takeRecords(): MutationRecord[] { return [] }
|
||||
constructor(private readonly callback: MutationCallback) { mutations.push(this) }
|
||||
deliver(): void { this.callback([], this) }
|
||||
}
|
||||
vi.stubGlobal('ResizeObserver', ControlledResize)
|
||||
vi.stubGlobal('MutationObserver', ControlledMutation)
|
||||
|
||||
function fixture(initial: Layout = wide(), ref = createPluginHeaderRef()) {
|
||||
const body = document.createElement('div')
|
||||
const bar = document.createElement('div')
|
||||
const header = document.createElement('header')
|
||||
const content = document.createElement('div')
|
||||
const index = releases.length
|
||||
const elements = new Map<PartName, HTMLDivElement>()
|
||||
for (const name of Object.keys(initial.resting.parts) as PartName[]) {
|
||||
const part = document.createElement('div')
|
||||
part.dataset.pluginHeaderPart = name
|
||||
part.id = `header-${index}-${name}`
|
||||
if (name === 'toolbar' || name === 'intro') {
|
||||
const button = document.createElement('button')
|
||||
button.id = `header-${index}-${name}-button`
|
||||
button.textContent = name
|
||||
part.append(button)
|
||||
} else part.textContent = name
|
||||
header.append(part)
|
||||
elements.set(name, part)
|
||||
}
|
||||
bar.append(header)
|
||||
content.textContent = 'Plugin cards'
|
||||
body.append(bar, content)
|
||||
document.body.append(body)
|
||||
releases.push(() => { ref(null); body.remove() })
|
||||
const state = { layout: initial }
|
||||
const clones: HTMLDivElement[] = []
|
||||
const measurements: {
|
||||
compact: boolean
|
||||
inert: boolean
|
||||
ariaHidden: string | null
|
||||
ids: number
|
||||
styles: number
|
||||
natural: boolean
|
||||
scrolled: boolean
|
||||
connected: boolean
|
||||
width: string
|
||||
}[] = []
|
||||
vi.spyOn(bar, 'getBoundingClientRect').mockImplementation(() => new DOMRect(50, 100, state.layout.width, state.layout.resting.height))
|
||||
const cloneNode = bar.cloneNode.bind(bar)
|
||||
vi.spyOn(bar, 'cloneNode').mockImplementation((deep) => {
|
||||
const clone = cloneNode(deep) as HTMLDivElement
|
||||
clones.push(clone)
|
||||
const cloneHeader = clone.querySelector('header')!
|
||||
// A different viewport origin ensures only header-relative coordinates drive the live controls.
|
||||
vi.spyOn(cloneHeader, 'getBoundingClientRect').mockImplementation(() => {
|
||||
measurements.push({
|
||||
compact: clone.hasAttribute('data-compact'), inert: clone.inert,
|
||||
ariaHidden: clone.getAttribute('aria-hidden'), ids: clone.querySelectorAll('[id]').length,
|
||||
styles: clone.querySelectorAll('[data-plugin-header-part][style]').length,
|
||||
natural: clone.hasAttribute('data-natural'), scrolled: clone.hasAttribute('data-scrolled'),
|
||||
connected: clone.isConnected, width: clone.style.width,
|
||||
})
|
||||
return new DOMRect(180, 240, state.layout.width, 0)
|
||||
})
|
||||
vi.spyOn(clone, 'getBoundingClientRect').mockImplementation(() =>
|
||||
new DOMRect(180, 240, state.layout.width, endpoint().height))
|
||||
function endpoint() { return clone.hasAttribute('data-compact') ? state.layout.compact : state.layout.resting }
|
||||
for (const name of elements.keys()) {
|
||||
const part = clone.querySelector<HTMLElement>(`[data-plugin-header-part="${name}"]`)!
|
||||
vi.spyOn(part, 'getBoundingClientRect').mockImplementation(() => {
|
||||
const bounds = endpoint().parts[name]!
|
||||
return new DOMRect(180 + bounds.x, 240 + bounds.y, bounds.width, bounds.height)
|
||||
})
|
||||
}
|
||||
return clone
|
||||
})
|
||||
function part(name: PartName): HTMLDivElement {
|
||||
const element = elements.get(name)
|
||||
if (element === undefined) throw new Error(`Fixture has no ${name}`)
|
||||
return element
|
||||
}
|
||||
function scroll(top: number): void {
|
||||
body.scrollTop = top
|
||||
body.dispatchEvent(new Event('scroll'))
|
||||
}
|
||||
return { body, bar, header, content, state, clones, measurements, part, scroll, ref, attach: () => { ref(bar) } }
|
||||
}
|
||||
return { fonts, resizes, mutations, fixture }
|
||||
}
|
||||
|
||||
describe('plugin header scroll geometry', () => {
|
||||
it.each([0, 12, 600])('clamps each trajectory and reverses without using a %ipx scroll range', (range) => {
|
||||
const h = harness(), f = h.fixture()
|
||||
Object.defineProperties(f.body, { clientHeight: { value: 500 }, scrollHeight: { value: 500 + range } })
|
||||
f.attach()
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-travel')).toBe('84px')
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px')
|
||||
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px')
|
||||
f.scroll(6)
|
||||
expect(f.part('toolbar').style.translate).toBe('0px -6px')
|
||||
expect(f.part('tabs').style.translate).toBe('0px -6px')
|
||||
expect(f.bar.hasAttribute('data-compact')).toBe(false)
|
||||
expect(f.bar.hasAttribute('data-scrolled')).toBe(true)
|
||||
f.scroll(20)
|
||||
expect(f.part('toolbar').style.translate).toBe('0px -12px')
|
||||
expect(f.part('tabs').style.translate).toBe('0px -20px')
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('120px')
|
||||
f.scroll(83)
|
||||
expect(f.bar.hasAttribute('data-compact')).toBe(false)
|
||||
f.scroll(84)
|
||||
expect(f.bar.hasAttribute('data-compact')).toBe(true)
|
||||
expect(f.part('title').style.translate).toBe('0px -56px')
|
||||
expect(f.part('intro').style.translate).toBe('0px -80px')
|
||||
expect(f.part('tabs').style.translate).toBe('0px -84px')
|
||||
f.scroll(1_000)
|
||||
expect(f.part('tabs').style.translate).toBe('0px -84px')
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('64px')
|
||||
f.scroll(6)
|
||||
expect(f.part('toolbar').style.translate).toBe('0px -6px')
|
||||
expect(f.part('intro').inert).toBe(false)
|
||||
expect(f.bar.hasAttribute('data-compact')).toBe(false)
|
||||
f.scroll(-10)
|
||||
expect(f.part('tabs').style.translate).toBe('0px 0px')
|
||||
expect(f.bar.hasAttribute('data-scrolled')).toBe(false)
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('140px')
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('140px')
|
||||
expect(f.body.scrollHeight - f.body.clientHeight).toBe(range)
|
||||
expect(f.content.style.cssText).toBe('')
|
||||
})
|
||||
|
||||
it('accounts for a layout scrollbar without retaining its sizing property after detachment', () => {
|
||||
const h = harness(), f = h.fixture()
|
||||
Object.defineProperties(f.body, { offsetWidth: { value: 960 }, clientWidth: { value: 955 } })
|
||||
f.attach()
|
||||
expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('5px')
|
||||
f.ref(null)
|
||||
expect(f.body.style.getPropertyValue('--plugin-page-scrollbar-width')).toBe('')
|
||||
})
|
||||
|
||||
it('interpolates narrow wrapped controls to their measured horizontal and width endpoints', () => {
|
||||
const h = harness(), f = h.fixture(narrow())
|
||||
f.attach()
|
||||
expect(f.part('toolbar').style.getPropertyValue('--plugin-part-y')).toBe('90px')
|
||||
expect(f.part('toolbar').style.getPropertyValue('--plugin-part-width')).toBe('280px')
|
||||
f.scroll(37)
|
||||
expect(f.part('toolbar').style.translate).toBe('100px -37px')
|
||||
expect(f.part('toolbar').style.width).toBe('180px')
|
||||
f.scroll(144)
|
||||
expect(f.part('toolbar').style.translate).toBe('200px -74px')
|
||||
expect(f.part('toolbar').style.width).toBe('80px')
|
||||
expect(f.part('tabs').style.translate).toBe('0px -144px')
|
||||
expect(f.part('tabs').style.width).toBe('160px')
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-paint-height')).toBe('70px')
|
||||
f.scroll(0)
|
||||
expect(f.part('toolbar').style.translate).toBe('0px 0px')
|
||||
expect(f.part('toolbar').style.width).toBe('280px')
|
||||
expect(f.part('tabs').style.width).toBe('280px')
|
||||
})
|
||||
|
||||
it('clips the no-tabs intro beneath the pinned title and restores interaction when scrolling back', () => {
|
||||
const h = harness(), f = h.fixture(wide(false))
|
||||
f.attach()
|
||||
f.scroll(16)
|
||||
expect(f.part('title').style.translate).toBe('0px -12px')
|
||||
expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)')
|
||||
expect(f.part('intro').inert).toBe(false)
|
||||
f.scroll(26)
|
||||
expect(f.part('intro').style.translate).toBe('0px -26px')
|
||||
expect(f.part('intro').style.clipPath).toBe('inset(50% 0 0)')
|
||||
expect(f.part('intro').inert).toBe(false)
|
||||
f.scroll(36)
|
||||
expect(f.part('intro').style.clipPath).toBe('inset(100% 0 0)')
|
||||
expect(f.part('intro').inert).toBe(true)
|
||||
expect(f.bar.hasAttribute('data-compact')).toBe(true)
|
||||
f.scroll(0)
|
||||
expect(f.part('intro').style.clipPath).toBe('inset(0% 0 0)')
|
||||
expect(f.part('intro').inert).toBe(false)
|
||||
})
|
||||
|
||||
it('scrolls the tabs intro fully out without the no-tabs clipping treatment', () => {
|
||||
const h = harness(), f = h.fixture()
|
||||
f.part('intro').style.clipPath = 'inset(100% 0 0)'
|
||||
f.attach()
|
||||
f.scroll(79)
|
||||
expect(f.part('intro').style.clipPath).toBe('')
|
||||
expect(f.part('intro').inert).toBe(false)
|
||||
f.scroll(80)
|
||||
expect(f.part('intro').style.translate).toBe('0px -80px')
|
||||
expect(f.part('intro').inert).toBe(true)
|
||||
f.scroll(40)
|
||||
expect(f.part('intro').inert).toBe(false)
|
||||
expect(f.part('intro').style.translate).toBe('0px -40px')
|
||||
})
|
||||
|
||||
it.each(['resize', 'mutation', 'font'] as const)('remeasures changed endpoints on %s without replacing focused controls', (event) => {
|
||||
const h = harness(), f = h.fixture()
|
||||
const button = f.part('toolbar').querySelector('button')!
|
||||
const clicked = vi.fn()
|
||||
button.addEventListener('click', clicked)
|
||||
f.attach()
|
||||
f.scroll(144)
|
||||
button.focus()
|
||||
f.state.layout = narrow()
|
||||
if (event === 'resize') h.resizes[0]!.deliver()
|
||||
else if (event === 'mutation') h.mutations[0]!.deliver()
|
||||
else h.fonts.dispatchEvent(new Event('loadingdone'))
|
||||
expect(f.part('toolbar').style.translate).toBe('200px -74px')
|
||||
expect(f.part('toolbar').style.width).toBe('80px')
|
||||
expect(f.bar.style.getPropertyValue('--plugin-header-rest-height')).toBe('210px')
|
||||
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px')
|
||||
expect(document.activeElement).toBe(button)
|
||||
expect(f.part('toolbar').querySelector('button')).toBe(button)
|
||||
button.click()
|
||||
expect(clicked).toHaveBeenCalledTimes(1)
|
||||
expect(f.clones).toHaveLength(2)
|
||||
expect(f.clones.every(clone => !clone.isConnected)).toBe(true)
|
||||
expect(f.body.querySelector('[data-measuring]')).toBeNull()
|
||||
expect(f.measurements.slice(-2)).toEqual([false, true].map(compact => ({
|
||||
compact, inert: true, ariaHidden: 'true', ids: 0, styles: 0,
|
||||
natural: false, scrolled: false, connected: true, width: '320px',
|
||||
})))
|
||||
const ids = [...f.body.querySelectorAll('[id]')].map(element => element.id)
|
||||
expect(new Set(ids).size).toBe(ids.length)
|
||||
expect(h.resizes[0]!.observe).toHaveBeenCalledWith(f.body)
|
||||
expect(h.mutations[0]!.observe).toHaveBeenCalledWith(f.header, {
|
||||
childList: true, characterData: true, subtree: true, attributes: true, attributeFilter: ['hidden'],
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the inert measurement copy when a compact layout read fails', () => {
|
||||
const h = harness(), f = h.fixture()
|
||||
const failure = new Error('Layout read interrupted')
|
||||
const resting = f.state.layout.resting
|
||||
Object.defineProperty(f.state, 'layout', {
|
||||
get: () => ({
|
||||
width: 960,
|
||||
resting,
|
||||
get compact(): Layout['compact'] { throw failure },
|
||||
}),
|
||||
})
|
||||
expect(() => { f.attach() }).toThrow(failure)
|
||||
expect(f.clones).toHaveLength(1)
|
||||
expect(f.clones[0]!.isConnected).toBe(false)
|
||||
expect(f.body.querySelector('[data-measuring]')).toBeNull()
|
||||
expect(h.resizes).toHaveLength(0)
|
||||
expect(h.mutations).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('disconnects observers and listeners and ignores callbacks already queued before detachment', () => {
|
||||
const h = harness(), f = h.fixture()
|
||||
f.attach()
|
||||
f.scroll(20)
|
||||
const before = f.bar.outerHTML
|
||||
f.ref(null)
|
||||
expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(h.mutations[0]!.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(f.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('')
|
||||
f.state.layout = narrow()
|
||||
f.scroll(144)
|
||||
h.resizes[0]!.deliver()
|
||||
h.mutations[0]!.deliver()
|
||||
h.fonts.dispatchEvent(new Event('loadingdone'))
|
||||
expect(f.bar.outerHTML).toBe(before)
|
||||
expect(f.clones).toHaveLength(1)
|
||||
f.ref(null)
|
||||
expect(h.resizes[0]!.disconnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rebinds one ref without allowing the old element or its callbacks to change the replacement', () => {
|
||||
const h = harness(), ref = createPluginHeaderRef(), first = h.fixture(wide(), ref), second = h.fixture(narrow(), ref)
|
||||
first.attach()
|
||||
first.scroll(20)
|
||||
const oldMarkup = first.bar.outerHTML
|
||||
second.attach()
|
||||
second.scroll(37)
|
||||
const newMarkup = second.bar.outerHTML
|
||||
first.scroll(144)
|
||||
h.resizes[0]!.deliver()
|
||||
h.mutations[0]!.deliver()
|
||||
expect(first.bar.outerHTML).toBe(oldMarkup)
|
||||
expect(second.bar.outerHTML).toBe(newMarkup)
|
||||
expect(first.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('')
|
||||
expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('78px')
|
||||
expect(first.clones).toHaveLength(1)
|
||||
expect(second.clones).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('keeps two mounted refs independent when scrolling, remeasuring, and disposing either instance', () => {
|
||||
const h = harness(), first = h.fixture(), second = h.fixture(narrow())
|
||||
first.attach()
|
||||
second.attach()
|
||||
first.scroll(20)
|
||||
expect(second.part('toolbar').style.translate).toBe('0px 0px')
|
||||
second.scroll(37)
|
||||
expect(first.part('toolbar').style.translate).toBe('0px -12px')
|
||||
expect(second.part('toolbar').style.translate).toBe('100px -37px')
|
||||
first.ref(null)
|
||||
const oldMarkup = first.bar.outerHTML
|
||||
second.state.layout = wide()
|
||||
h.fonts.dispatchEvent(new Event('loadingdone'))
|
||||
expect(first.bar.outerHTML).toBe(oldMarkup)
|
||||
expect(first.clones).toHaveLength(1)
|
||||
expect(second.clones).toHaveLength(2)
|
||||
expect(second.part('toolbar').style.translate).toBe('0px -12px')
|
||||
expect(second.body.style.getPropertyValue('--plugin-header-scroll-padding')).toBe('72px')
|
||||
expect(h.resizes[1]!.disconnect).not.toHaveBeenCalled()
|
||||
second.scroll(0)
|
||||
expect(second.part('toolbar').style.translate).toBe('0px 0px')
|
||||
})
|
||||
})
|
||||
@@ -15,11 +15,11 @@
|
||||
en: d152484eb41ac6b4
|
||||
zh: 09388d293f9be9cb
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package:
|
||||
en: d2f5701274be5110
|
||||
zh: d60e2617136a6f26
|
||||
en: 10be7f75a1576575
|
||||
zh: abf2905d89b0da48
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
|
||||
en: 8e9f354ef0f81d22
|
||||
zh: 8ee59b6bb7a7198e
|
||||
en: 75e1681dcfc61a14
|
||||
zh: 3eda3143730f83e1
|
||||
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
|
||||
en: dbd7f0e74c45a762
|
||||
zh: 7efbfbf5500cc835
|
||||
|
||||
@@ -27,6 +27,8 @@ Use `dsh-client-ui-primitives` to build web-client controls and render agent out
|
||||
|
||||
`Toast` uses the theme’s toast background and label colors in both light and dark modes. `Menu` data rows and `MenuItemButton` component rows accept owner-provided effective shortcuts and align their keys at the trailing edge as muted text without a background, including submenus. Menus hide these hints when the primary pointer is coarse (`pointer: coarse`) and [keyboard navigation](#input-modality) is inactive. Keyboard navigation reveals them until the next pointer press; keyboard bindings remain active. `ShortcutKeys` defaults to unboxed keys for menu and inline hints. Its `tooltip` variant uses lighter keycaps over the dark bubble and groups plus-separated combinations into one filled block. `Tooltip.shortcutKeys` vertically centers keycaps beside optional localized action text, or shows only keys when the label is empty. The optional `className` supplies owner interaction styles. `Modal` uses the frame’s shared overlay inset, with a minimum 24px viewport margin. Its mask leaves the Windows caption unpainted while the full-viewport layer blocks background clicks. `Modal` and the settings shell share top-layer Escape and Tab handling through `useModalLayer`, restoring the previous focus on close. `closeTopModal(document)` requests the foreground modal’s current close action; newer menus or unregistered dialogs block dismissal of the modal behind them. Components use `isBehindModal` to avoid moving focus behind a nested dialog. Menus consume their local Escape before modal dismissal. `observeComposition` supplies the same composition-end and legacy IME guard to local modal and recording handlers; callers dispose its document listeners with their interaction lifetime.
|
||||
|
||||
`isMobileDevice()` identifies iOS and Android phones and tablets for automatic text-input focus and keyboard dismissal. It recognizes `Android`, `iPhone`, `iPad`, or `iPod` in the UA, and iPads reporting `MacIntel` with more than one touch point. Other devices, including Windows touchscreen computers, return false regardless of window width. The function caches its first result for all callers in the page; component remounts reuse that result, and reloading the page reads the browser environment again. Identified mobile devices require explicit text-input focus. Browser modes or custom UAs that conceal the mobile platform can prevent identification; unrecognized UAs return false. A desktop UA alone cannot distinguish Android tablets from Linux computers.
|
||||
|
||||
Automatic modal entry and modal/menu return focus, including after Escape and application close shortcuts, use `focusWithoutRing(element, options?)` to suppress the outline until Tab or directional navigation resumes normal focus styling. Dialog containers keep their outline-free styling. Existing borders, shadows, and error states remain intact. Mark a dialog's initial control with `data-modal-autofocus` so the modal captures its invoking control before moving focus. Controls mounted with the dialog must not use React `autoFocus`, which runs before that capture. Tab and Shift+Tab from the dialog container enter its first and last focusable controls.
|
||||
|
||||
This package is a Web-shell build input. Its static ESM retains third-party imports and styles for Vite; independent consumers supply its development dependencies ([dependency rules](../AGENTS.md#dependency-declaration)).
|
||||
@@ -50,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
|
||||
| `MenuGroup`, `observeStickyMenuGroups` | Localized, accessible groups for custom menus and listboxes, with shared sticky-heading styling and native intersection/size observation; the caller owns the observer's lifetime. |
|
||||
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
||||
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
|
||||
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
|
||||
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
|
||||
| `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. |
|
||||
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader whose animations pin to document time zero on mount and on every animation restart, such as a list reorder, so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. |
|
||||
|
||||
@@ -27,6 +27,8 @@ kind: "package-library"
|
||||
|
||||
`Toast` 在浅色和深色模式下均使用主题的系统提示背景与文字颜色。`Menu` 数据条目与 `MenuItemButton` 组件条目接收功能 owner 提供的有效快捷键,并在末端以无背景的浅灰色文字对齐显示,子菜单也采用同一呈现。主指针为粗指针(`pointer: coarse`)且未使用[键盘导航](#input-modality)时,菜单隐藏这些提示。键盘导航会显示提示,直到下一次指针按下;快捷键绑定始终生效。`ShortcutKeys` 默认以无方块的按键文字显示菜单和行内提示。`tooltip` 变体在深色气泡上使用稍浅的键帽,以加号连接的组合则共用一个填充色块。`Tooltip.shortcutKeys` 将键帽与可选的本地化操作文本垂直居中排列,标签为空时只显示按键。可选的 `className` 供调用方设置交互状态样式。`Modal` 使用框架共享的浮层起点,视口边距至少为 24px。其遮罩不绘制 Windows 顶栏区域,覆盖整个视口的模态层仍阻挡背景点击。`Modal` 与设置外壳通过 `useModalLayer` 共用顶层 Esc 和 Tab 处理,关闭时恢复先前焦点。`closeTopModal(document)` 请求前台弹窗当前的关闭操作;上层菜单或未注册的对话框会阻止关闭其后方弹窗。组件通过 `isBehindModal` 避免将焦点移到嵌套弹窗后方。菜单先消费自己的 Esc,再由模态层处理关闭。 `observeComposition` 为局部弹层和录键处理提供相同的 composition-end 与旧版 IME 保护;调用方随交互生命周期释放其 document 监听。
|
||||
|
||||
`isMobileDevice()` 识别 iOS 和 Android 手机及平板,供自动聚焦和收起键盘使用。它识别 UA 中的 `Android`、`iPhone`、`iPad` 或 `iPod`,以及报告 `MacIntel` 且触摸点数大于一的 iPad。其他设备(包括 Windows 触屏电脑)返回 false,不受窗口宽度影响。函数缓存首次判断结果,供页面内的所有调用方共享;组件重新挂载会复用该结果,重新加载页面时会再次读取浏览器环境。识别出的移动设备需要用户主动聚焦文本输入框。隐藏移动平台信息的浏览器模式或自定义 UA 可能导致识别失败;未识别的 UA 返回 false。仅凭桌面 UA 无法区分 Android 平板与 Linux 电脑。
|
||||
|
||||
弹窗自动进入及弹窗、菜单回焦,包括通过 Esc 和应用关闭快捷键触发的回焦,均使用 `focusWithoutRing(element, options?)`,在 Tab 或方向键导航恢复正常焦点样式前抑制外轮廓线。弹窗容器仍不绘制焦点外框。原有边框、阴影和错误状态保持不变。用 `data-modal-autofocus` 标记弹窗的初始控件,让模态层先保存触发控件,再移动焦点。随弹窗挂载的控件不得使用 React `autoFocus`,因为它会在保存触发控件前执行。弹窗容器获得焦点时,Tab 和 Shift+Tab 分别进入第一个和最后一个可聚焦控件。
|
||||
|
||||
本包是 Web 壳的构建输入。静态 ESM 为 Vite 保留第三方导入和样式;独立消费方自行提供开发依赖([依赖规则](../AGENTS.md#dependency-declaration))。
|
||||
@@ -50,7 +52,7 @@ kind: "package-library"
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 |
|
||||
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
||||
| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 |
|
||||
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画在挂载时以及每次动画重启(如列表重排)时固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 |
|
||||
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user