diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 3f8444bcfa..d4ad622140 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 91e212ad9f..74e88534ee 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -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. diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 27b7777f23..ac5f935c92 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -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) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml new file mode 100644 index 0000000000..4a2fe2ba9d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md new file mode 100644 index 0000000000..c3e74990d7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.md @@ -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`. diff --git a/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md new file mode 100644 index 0000000000..9c4b0f6948 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-27-web-search-offered-by-provider-readiness.zh.md @@ -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`。 diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml new file mode 100644 index 0000000000..59918ce6a1 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-21-replaceable-icon-sprite.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md +/agent-note-replaceable-product-icons-through-an-svg-sprite: + en: 2b527e550e071451 + zh: 7de256912270cae0 +/agent-note-replaceable-product-icons-through-an-svg-sprite/problem: + en: d55557fe41ab87c8 + zh: eea3f7d0c82561bf +/agent-note-replaceable-product-icons-through-an-svg-sprite/decision: + en: 6ddd80eae56693dd + zh: 4d654a76eb64c50a +/agent-note-replaceable-product-icons-through-an-svg-sprite/alternatives-considered: + en: 2e25ece88bccdb22 + zh: bb24a28bef7ea7ce +/agent-note-replaceable-product-icons-through-an-svg-sprite/consequences: + en: 0ad6db37bbb76ddb + zh: 0ca09d55a2167970 diff --git a/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md new file mode 100644 index 0000000000..f4e228ee02 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-21-replaceable-icon-sprite.md @@ -0,0 +1,29 @@ +# Agent Note: Replaceable product icons through an SVG sprite + +Status: implemented + +English | [中文](2026-10-21-replaceable-icon-sprite.zh.md) + +## Problem + +Long-term skin customization needs themes to replace icons, not only colors. Product icons were React components that drew inline SVG paths, so a theme registered through `ctx.theme` could recolor them through `currentColor` but could not change their artwork. About 150 Client files import these components. + +## Decision + +Each product glyph weight is one sprite symbol, `dsw-icon--`. ui-primitives keeps the default SVG documents as `ICON_SYMBOLS`; an icon component keeps its export name, `size`, and `className`, and renders an `` box with one `` of its symbol. ui-theme keeps one hidden sprite in the document whose `` children come from the active theme's `ThemeDefinition.icons` replacements or the defaults. Artwork reached through `` inherits `currentColor` and CSS custom properties from the use site, so a replacement can be multi-color, can paint with `--dsw-*` color tokens, and follows light and dark palettes without separate values. + +## Alternatives considered + +- **Icons as CSS image tokens painted through `mask-image`.** Rejected after design review: a mask keeps only the image's alpha, so every icon stays one color, and a `url()` image cannot read CSS variables, so multi-color artwork could not follow color tokens. It would have reused the token override layer without a new theme field. +- **A runtime icon registry service with React components as values.** Rejected: components cannot read services or React context, so every icon import site would need a new prop or slot. +- **A slot per icon.** Rejected for the same caller cost; slots carry composition, not artwork. + +## Consequences + +- Icon DOM contains a `` reference instead of paths; tests identify glyphs through `data-icon` and inspect artwork through `ICON_SYMBOLS`. +- Without ui-theme, the sprite is absent and icons render nothing. +- Every symbol shares the document id space, so `register` requires element ids inside a replacement to start with `-`; `` in another document, such as an iframe, cannot reach the sprite. +- Replacement markup is trusted like the registering plugin's code; `register` rejects ` { - 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: , diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 8e9041c151..67ad49f958 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -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. */ diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index ad0e4ce4bd..42e4d52e1a 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -17,7 +17,7 @@ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useStat import type { ChangeEvent, KeyboardEvent, MouseEvent } from 'react' import clsx from 'clsx' import { - IconPlusOutlineMedium, IconWarningOutlineRegular, Toast, Tooltip, + IconPlusOutlineMedium, IconWarningOutlineRegular, isMobileDevice, Toast, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the // composer reads a host-computed value; the domain owns the key). @@ -31,7 +31,7 @@ import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' import type { ComposerBarProps } from '../contract/slots.ts' import { DraftEditor } from '../input/editor/DraftEditor.tsx' import { - focusDraftEditor, installDraftFilePicker, installDraftKeymap, installDraftWheel, + focusDraftEditor, installDraftFilePicker, installDraftKeyboardDismissal, installDraftKeymap, installDraftWheel, keepDraftFocus, revealDraftSelection, } from '../input/editor/view-binding.ts' import { resolveSubmitMode } from '../input/submission-policy.ts' @@ -171,13 +171,11 @@ export const InputBar = memo(function InputBar({ revealDraftSelection(scrollRef) } - // Unlock (mount / session switch) returns focus to the box, and owns the - // reveal that comes with it. Lexical's focus() suppresses the browser's - // scroll walk (preventScroll inside), so the reveal in our own scrollport - // is ours to perform — switching to a longer draft otherwise leaves the - // caret (restored at the draft's end) off screen. + // Automatic desktop focus restores and reveals the draft selection without + // scrolling the conversation. useEffect(() => { if (locked || editor === null) return + if (isMobileDevice()) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -268,6 +266,11 @@ export const InputBar = memo(function InputBar({ return installDraftKeymap(editor, keyboard, gate) }, [editor, keyboard]) + useEffect(() => { + if (editor === null) return + return installDraftKeyboardDismissal(editor, cardRef) + }, [editor]) + // Button presses steal focus from the editor; suppress at mousedown so // typing continues seamlessly. Lexical's focus() carries preventScroll and // restores the previous selection, so no reveal is needed: the caret has @@ -283,7 +286,7 @@ export const InputBar = memo(function InputBar({ if (target.closest('[data-composer-overlay]') !== null) return if (scrollRef.current?.contains(target)) return const control = target.closest('button, a, input, select, textarea, [role="button"], [contenteditable], [tabindex]') - if (control !== null && e.currentTarget.contains(control)) return + if (control !== null && control !== e.currentTarget && e.currentTarget.contains(control)) return e.preventDefault() // Lexical's stored selection can lag behind native caret moves, so calling // editor.focus() on a focused editor can restore an older caret. @@ -391,6 +394,7 @@ export const InputBar = memo(function InputBar({ click's reopen (close-then-open flickers the chip's open echo). */}
{ 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() diff --git a/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts b/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts new file mode 100644 index 0000000000..b000350885 --- /dev/null +++ b/packages/client/ui-conversation/tests/editor-viewport.client.spec.ts @@ -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) +}) diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index e1f61e54d1..9858192830 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -8,6 +8,7 @@ // root listener routes them through the keymap commands); draft writes drive // the shell (jsdom's beforeinput lacks the ranges Lexical needs). +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' import './control-row-dom.ts' import type { InboxState } from '@deepseek-ai/dsh-agent/types' import type { GlobalStandardProps } from '@deepseek-ai/dsh-client-ui-slots' @@ -1449,6 +1450,81 @@ describe('running and lock semantics', () => { expect(focused).toEqual([true]) }) + it.each([ + { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false }, + { name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false }, + { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false }, + { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false }, + { name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true }, + { name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true }, + ])('composer focus and submission respect device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const outside = document.createElement('input') + document.body.append(outside) + onTestFinished(() => { outside.remove() }) + outside.focus() + + const { view, textarea, props, shell, button, sink } = bench({ draft: 'draft' }) + expect(document.activeElement).toBe(automatic ? textarea : outside) + act(() => { outside.focus() }) + act(() => { view.rerender() }) + expect(document.activeElement).toBe(automatic ? textarea : outside) + act(() => { outside.focus() }) + act(() => { view.rerender() }) + act(() => { view.rerender() }) + 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') diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index 2f06976029..f631fbecc2 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index 0ba54c2c8e..976148bde2 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -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. diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 39acc02792..85ad53eb45 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -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 和方向键导航保留标签页的可见焦点提示。输入法组合输入和长按重复事件不关闭菜单。 diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index eea2ea1724..846a4b75e8 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -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, dropZones: 'edges' | 'horizontal', @@ -126,7 +129,10 @@ function hitTest( const strip = pane.querySelector('[data-dockkit-strip]') if (strip !== null && containsPoint(strip.getBoundingClientRect(), x, y)) { const tabs = [...strip.querySelectorAll('[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)) diff --git a/packages/client/ui-dockkit/tests/components.client.spec.tsx b/packages/client/ui-dockkit/tests/components.client.spec.tsx index 85640ed768..9a44d625f4 100644 --- a/packages/client/ui-dockkit/tests/components.client.spec.tsx +++ b/packages/client/ui-dockkit/tests/components.client.spec.tsx @@ -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]) diff --git a/packages/client/ui-model-selection/README.i18n.yaml b/packages/client/ui-model-selection/README.i18n.yaml index fc05dbc20d..177bc09fb2 100644 --- a/packages/client/ui-model-selection/README.i18n.yaml +++ b/packages/client/ui-model-selection/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-model-selection/README.md b/packages/client/ui-model-selection/README.md index 5c181ca2ac..b2c809123f 100644 --- a/packages/client/ui-model-selection/README.md +++ b/packages/client/ui-model-selection/README.md @@ -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. diff --git a/packages/client/ui-model-selection/README.zh.md b/packages/client/ui-model-selection/README.zh.md index 9755602564..e99a1e05ce 100644 --- a/packages/client/ui-model-selection/README.zh.md +++ b/packages/client/ui-model-selection/README.zh.md @@ -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 色。搜索内容非空时显示清除按钮;点击后恢复完整列表,并将焦点留在搜索框。 diff --git a/packages/client/ui-model-selection/src/client/ModelSelect.tsx b/packages/client/ui-model-selection/src/client/ModelSelect.tsx index c497bcbe77..59dbf85427 100644 --- a/packages/client/ui-model-selection/src/client/ModelSelect.tsx +++ b/packages/client/ui-model-selection/src/client/ModelSelect.tsx @@ -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 } diff --git a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx index 54c606f636..bb4832a0ce 100644 --- a/packages/client/ui-model-selection/tests/model-select.client.spec.tsx +++ b/packages/client/ui-model-selection/tests/model-select.client.spec.tsx @@ -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() + 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' ? /^模型/ : /推理等级/ }) diff --git a/packages/client/ui-plugin-manager/README.i18n.yaml b/packages/client/ui-plugin-manager/README.i18n.yaml index 66bac65580..16a9ce7a2c 100644 --- a/packages/client/ui-plugin-manager/README.i18n.yaml +++ b/packages/client/ui-plugin-manager/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-plugin-manager/README.md b/packages/client/ui-plugin-manager/README.md index 99c1a4fb43..5a659bc446 100644 --- a/packages/client/ui-plugin-manager/README.md +++ b/packages/client/ui-plugin-manager/README.md @@ -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 `/icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information. diff --git a/packages/client/ui-plugin-manager/README.zh.md b/packages/client/ui-plugin-manager/README.zh.md index 996dba9132..f58f0478c5 100644 --- a/packages/client/ui-plugin-manager/README.zh.md +++ b/packages/client/ui-plugin-manager/README.zh.md @@ -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 信息。 diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css index 9c51e514d6..6e779d10df 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css @@ -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; diff --git a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx index 5adf279a1f..95020799b0 100644 --- a/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx +++ b/packages/client/ui-plugin-manager/src/client/PluginManagerPage.tsx @@ -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 + & InjectFace & PropsStore> /** 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 ( - + + + { if (tab !== selectedTab) setTab(tab) }, [tab, selectedTab, setTab]) const tabsId = useId() const [activation, setActivation] = useState(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 } @@ -1645,64 +1649,6 @@ export function PluginManagerPage(props: PluginManagerPageProps): ReactNode { return (
- {showsMainList - ? ( -
-
-

{t('title')}

-
- {t('intro')} - - - -
-
- -
- ) - : null} - {showsMainList && tabs.length > 0 - ? ( -
- { 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` })), - ]} - /> -
- ) - : null} - {!onInstalled - ? ( -
- {renderSlot('plugins.tab', { - install: props.installFrom, - openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, - }, { only: tab })} -
- ) - : null} {showsMoreList ? (
) : null} - {installedCards && !loaded ? ( -
- {state.status === 'loading' ? : null} - {state.status === 'unavailable' ? ( -

- {t('unavailable')} -

- ) : null} -
- ) : null} - {state.notice === null || noticeLine === null - ? null - : ( - } - holdMs={toastHoldMs(noticeLine)} - onDone={props.dismissNotice} - /> - )} - {!showsCards && state.status === 'error' && !refreshing - ? ( -
-

- {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} -

- -
- ) - : null} - {onInstalled && loaded && openPkg !== undefined && openRow !== undefined - ? ( - { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }} - /> - ) - : null} - {onInstalled && loaded && openPkg !== undefined && openRow === undefined - ? ( - row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} - configured={ledger.bundles.has(openPkg.name)} - configure={configure(openPkg)} - renderSlot={renderSlot} - inMore={backToMore} - detail={( - - )} - 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 - ? { setView({ kind: 'list' }) }} /> - : null} - {loaded && installedCards - ? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error' - ?

{t('empty')}

- : ( -
- {showsMoreList ? ( -
-
    {more.map(packageCard)}
-
- ) : ( - <> - {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 - ? ( -
-

- {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} -

- +
+ {showsMainList + ? ( +
0 ? ` ${css.pageBarWithTabs}` : ''}`} + data-plugin-bar + ref={headerRef} + > +
+
+

{t('title')}

+
+ {t('intro')} + + +
- ) - : null} +
+ + {tabs.length > 0 + ? ( +
+ { 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` })), + ]} + /> +
+ ) + : null} +
) - : 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 + ? ( +
+ {renderSlot('plugins.tab', { + install: props.installFrom, + openPackage: (name) => { setTab(INSTALLED_TAB); setView({ kind: 'package', name, returnTab: tab }) }, + }, { only: tab })} +
+ ) + : null} + {installedCards && !loaded ? ( +
+ {state.status === 'loading' ? : null} + {state.status === 'unavailable' ? ( +

+ {t('unavailable')} +

+ ) : null} +
+ ) : null} + {state.notice === null || noticeLine === null + ? null + : ( + } + holdMs={toastHoldMs(noticeLine)} + onDone={props.dismissNotice} + /> + )} + {!showsCards && state.status === 'error' && !refreshing + ? ( +
+

+ {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} +

+ +
+ ) + : null} + {onInstalled && loaded && openPkg !== undefined && openRow !== undefined + ? ( + { setView({ kind: 'package', name: openPkg.name, ...pkgOrigin }) }} + /> + ) + : null} + {onInstalled && loaded && openPkg !== undefined && openRow === undefined + ? ( + row.entryId !== undefined && state.busy.includes(rowKey(row.entryId))} + configured={ledger.bundles.has(openPkg.name)} + configure={configure(openPkg)} + renderSlot={renderSlot} + inMore={backToMore} + detail={( + + )} + 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 + ? { setView({ kind: 'list' }) }} /> + : null} + {loaded && installedCards + ? basicCards.length === 0 && official.length === 0 && mine.length === 0 && state.status !== 'error' + ?

{t('empty')}

+ : ( +
+ {showsMoreList ? ( +
+
    {more.map(packageCard)}
+
+ ) : ( + <> + {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 + ? ( +
+

+ {t(state.refreshStatus === 'failed' ? 'refreshError' : 'error')} +

+ +
+ ) + : null} +
+ ) + : 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} +
{state.update === null ? null : ( (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('[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 } => { + const head = (clone.querySelector('header') as HTMLElement).getBoundingClientRect() + const boxes = new Map() + for (const name of PARTS) { + const element = clone.querySelector(`[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 + let compact: ReturnType + 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') + } +} diff --git a/packages/client/ui-plugin-manager/src/client/index.ts b/packages/client/ui-plugin-manager/src/client/index.ts index 2a6a3a48da..54dce2da8b 100644 --- a/packages/client/ui-plugin-manager/src/client/index.ts +++ b/packages/client/ui-plugin-manager/src/client/index.ts @@ -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' }, diff --git a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx index a64c1ba05c..b2b196bfe1 100644 --- a/packages/client/ui-plugin-manager/tests/components.client.spec.tsx +++ b/packages/client/ui-plugin-manager/tests/components.client.spec.tsx @@ -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: [] }, {}, { diff --git a/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts b/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts new file mode 100644 index 0000000000..432dfdcb44 --- /dev/null +++ b/packages/client/ui-plugin-manager/tests/header-scroll.client.spec.ts @@ -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> } + compact: { height: number; parts: Partial> } +} + +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() + 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(`[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') + }) +}) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 732fbedef0..7795512361 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -15,14 +15,14 @@ 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: fff68b60f4abdc7e - zh: 5cf5952ec78f7f91 + en: dbd7f0e74c45a762 + zh: 7efbfbf5500cc835 /deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output: en: 02f310acbae29955 zh: 1e41393d5d714b2d @@ -33,8 +33,8 @@ en: 846ac0f563a53a64 zh: bb89d7647bdcc1ab /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/source-map: - en: 44eb32370cbeafe8 - zh: 728009c66375c6bb + en: fd510d34404eb090 + zh: d05b0890db929ae6 /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation/input-modality: en: cb61e1a37d6c27e5 zh: 1f3fa79dd5ab2e45 @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work: - en: 31117d1844eb3c19 - zh: 741eea7d78e00d3f + en: 5a1e9d041b556b0e + zh: c66b4e7d5f77162d /deepseek-ai-dsh-client-ui-primitives/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index a6492dc5a8..637ff7f55c 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -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. | @@ -94,7 +96,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions. Named components keep the chosen weight visible at import sites; the library owns stroke geometry instead of consumer CSS overrides. Each product icon renders an `` box with one `` of its `dsw-icon--` symbol, for example `dsw-icon-search-outline-regular`, and names that id in `data-icon`. `ICON_SYMBOLS` holds every symbol's default SVG document; ui-theme renders the document icon sprite from it, and an icon whose symbol is missing renders nothing. Artwork reached through `` inherits `currentColor` and CSS variables from the use site. Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic code glyph uses angle brackets and a slash at the supplied 1.35px stroke; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader in the same warning color as its label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; anchored overlays and portalled `Menu` add 20px to the frame's published top clearance, or keep 20px in native fullscreen, while preserving their own minimum margins in ordinary browsers. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Touch entry does not open its preview or intercept the anchor action. Mouse entry requires an available hover-capable pointer; keyboard focus remains independent of that capability. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `HoverCard` and nested action tooltips display independently; use `Tooltip.portal` to layer the small bubble above the card. A visible nested `Tooltip` suppresses only its enclosing tooltip, not the hover card. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. Portalled `Menu` lists follow their anchor during dragging and CSS transforms, and stop tracking when closed. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. `Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. It stays hidden until its first fit, prefers the requested side, then tries the opposite and perpendicular sides, sliding along the anchor without covering it when a side fits. Horizontal placements keep a 10px gap; vertical placements use `gap` (8px by default). Width and height stay within a 12px viewport margin; text wraps, and content taller than the viewport scrolls when pinned. If no side fits, a stable viewport-bounded fallback can overlap the anchor. Viewport resize refreshes the anchor coordinates; label-size changes reuse them. Fitting does not synchronously measure the bubble or trigger a React render. Touch presses dismiss unpinned hints and do not trigger hover hints, including compatibility mouse events. Mouse entry opens hints only while `(any-hover: hover)` reports an available hover-capable input, excluding iOS mouse entry caused by a tapped icon changing. Keyboard focus and explicit click-to-open explanations remain available on touch-only devices. Opt-in `openOnClick` lets an informational button pin the bubble by click or tap and associates it with the anchor description. Another click or tap, Escape, Tab, or an outside pointerdown closes it. Ordinary action buttons execute their action directly without a touch tooltip. @@ -157,7 +159,7 @@ The package enforces one separation: presentational React atoms with zero Cordis | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI escape parsing (`anser`) and terminal card rendering | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | Read and diff cards | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | Search and web-retrieval cards | -| [`src/icons/`](src/icons/) | Size-neutral `Regular` and `Medium` product glyph components | +| [`src/icons/`](src/icons/) | Default product glyph artwork, its `ICON_SYMBOLS` sprite documents, and the size-neutral `Regular` and `Medium` components that reference those symbols | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | Shared filename grammar selection and lazy line highlighting | | [`src/input-modality.ts`](src/input-modality.ts) | Document-wide input modality published on `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | Fixed-palette plugin artwork with per-instance SVG def ids | @@ -220,6 +222,7 @@ These limits define how the atoms behave at the edges; they are current package - **Overlong lines stay uncolored** — a source line of 1,000 or more UTF-16 units renders as one plain run in every highlighted surface, because TextMate scanning cost grows superlinearly with line length and a single generated line can otherwise block the page for seconds. The grammar state skips that line, so a comment or template literal that opens or closes on it mis-colors the following lines. Highlighting still runs synchronously during render, so a file with thousands of ordinary lines costs proportionally more. - **The fish logo is a redrawn approximation** — its font glyph has no exportable vector geometry in the local design data, so a hand-authored recreation stands in until an exact export path exists. - **`Pill` and `Input` have no design source** — both atoms are self-defined; the sidebar search field and view-tab strip that resemble them are consumer-owned compositions, not these atoms. +- **Product icons need the ui-theme sprite** — each icon only references its symbol, so without ui-theme, or inside another document such as an iframe, the icon renders nothing. - **No `Active` `StateDot` variant** — the supported states are done, warning, ongoing, error, and idle. - **User-facing copy is required at the render site** — the atoms are zero-Cordis and cannot reach `ctx.locale`; each feature must supply complete localized labels through the primitive's typed props ([decision](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). - **Display math keeps fallback shapes** — a `$$x$$ trailing` on the opening line resolves through upstream inline math and leaves the surrounding prose in place, while the same trailing text on a later line declines the block and upstream takes the prose up to the next lone `$$` or EOF; a block left open ends at the next lone `$$` or at EOF; a later fence behind a `%` comment that holds text closes nothing, so a block nothing else closes reaches the same fallback; a multi-line block whose opening line carries a solitary `$` stays in its paragraph unless a blank line separates them; and a container continuation line that leaves the item indentation hands the block to upstream, which drops the opening line. `$$` fence meta is no longer honored ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.md)). diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index d539aff861..e10743c8f9 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -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"` 以实心勾表示完成、空心圆表示等待。 | @@ -94,7 +96,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `` 框,其中包含一个引用对应 `dsw-icon--` symbol 的 ``,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `` 引用的图形会从使用处继承 `currentColor` 与 CSS 变量。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用代码图形使用尖括号与斜线,保留原图 1.35px 描边;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、与文案使用相同警告色的共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;锚定浮层与 portal 模式的 `Menu` 在框架发布的顶部占用量上增加 20px,原生全屏时保留 20px,普通浏览器中沿用自身的最小边距。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoverCard` 与嵌套的操作提示独立显示;使用 `Tooltip.portal` 将小气泡置于卡片上层。可见的嵌套 `Tooltip` 只隐藏外层 tooltip,不隐藏悬停卡片。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 `Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。 @@ -157,7 +159,7 @@ kind: "package-library" | [`src/TerminalBlock.tsx`](src/TerminalBlock.tsx) | ANSI 转义解析(`anser`)与终端卡片渲染 | | [`src/ReadBlock.tsx`](src/ReadBlock.tsx) / [`src/DiffBlock.tsx`](src/DiffBlock.tsx) | 读取与差异卡片 | | [`src/SearchBlock.tsx`](src/SearchBlock.tsx) / [`src/WebBlock.tsx`](src/WebBlock.tsx) | 搜索与网页检索卡片 | -| [`src/icons/`](src/icons/) | 与尺寸无关的 `Regular` 和 `Medium` 产品图标组件 | +| [`src/icons/`](src/icons/) | 默认产品图标图形、其 `ICON_SYMBOLS` sprite 文档,以及引用这些 symbol 的与尺寸无关的 `Regular` 和 `Medium` 组件 | | [`src/code-highlighting.ts`](src/code-highlighting.ts) | 共享的文件名 grammar 选择与惰性逐行高亮 | | [`src/input-modality.ts`](src/input-modality.ts) | 全文档输入模态,发布到 `` | | [`src/plugin-artwork.tsx`](src/plugin-artwork.tsx) | 固定配色插件插画,SVG def id 按实例生成 | @@ -220,6 +222,7 @@ kind: "package-library" - **超长行不着色**:所有高亮界面中,长度达到 1,000 个 UTF-16 单元的源码行渲染为一段纯文本,因为 TextMate 扫描成本随行长超线性增长,单个生成行否则会阻塞页面数秒。语法状态会跳过该行,因此在该行开始或结束的注释、模板字符串会使后续行着色错误。高亮仍在渲染期间同步执行,因此数千个普通行的文件仍按比例增加开销。 - **鱼形标志是重新绘制的近似版本**:它来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。 - **`Pill` 与 `Input` 没有设计来源**:两个原子组件均自行定义;与其相似的侧边栏搜索字段和视图标签条由消费方组合,不是这些原子组件。 +- **产品图标依赖 ui-theme 的 sprite**:每个图标只引用自己的 symbol,因此没有 ui-theme 时,或在 iframe 等另一个文档中,图标不渲染任何内容。 - **`StateDot` 没有 `Active` 变体**:支持的状态为 done、warning、ongoing、error 和 idle。 - **面向用户的文案必须由渲染点提供**:这些原子组件是 zero-Cordis 的,拿不到 `ctx.locale`;各功能必须通过原子组件的带类型 prop 提供完整本地化 label(见[决策](../../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.zh.md))。 - **显示数学保留若干回退形状**:开围栏行上的 `$$x$$ trailing` 走上游行内数学,前后正文保持不变;同样形态出现在后续行时块被放弃,随后由上游把正文吞到下一个独占行 `$$` 或文件结尾;未闭合的块在下一个独占行 `$$` 或文件结尾结束;后续行的围栏若位于**含文本**的 `%` 注释之后则不闭合,若无其他围栏闭合,块走同一条回退;开围栏行含孤立 `$` 的多行块会留在段落内,除非与前文空一行;容器内续行若离开条目缩进,块交由上游 flow 处理并丢弃开围栏行。`$$` 围栏 meta 不再生效(见[决策](../../../.agents/notes/implemented/bug-fix/2026-09-17-multiline-display-dollar-math.zh.md))。 diff --git a/packages/client/ui-primitives/src/SegmentedTabs.module.css b/packages/client/ui-primitives/src/SegmentedTabs.module.css index cb12951a11..a4edb7ada9 100644 --- a/packages/client/ui-primitives/src/SegmentedTabs.module.css +++ b/packages/client/ui-primitives/src/SegmentedTabs.module.css @@ -1,14 +1,16 @@ .tabs { + --segmented-tabs-inset: 4px; + position: relative; display: grid; - padding: 4px; + padding: var(--segmented-tabs-inset); border-radius: var(--dsw-radius-lg); background: var(--dsw-alias-bg-module-platform); } .indicator { position: absolute; - inset: 4px auto 4px 4px; + inset: var(--segmented-tabs-inset) auto var(--segmented-tabs-inset) var(--segmented-tabs-inset); box-sizing: border-box; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: var(--dsw-radius-md); @@ -46,6 +48,20 @@ font-weight: 600; } +.tabs[data-size='sm'] { + --segmented-tabs-inset: 2px; + + border-radius: calc(var(--dsw-radius-md) + var(--segmented-tabs-inset)); +} + +.tabs[data-size='sm'] .tab { + height: 30px; +} + +.tabs[data-size='sm'] .tab[aria-selected='true'] { + font-weight: 500; +} + .tab:focus-visible { outline: none; text-decoration: underline; diff --git a/packages/client/ui-primitives/src/SegmentedTabs.tsx b/packages/client/ui-primitives/src/SegmentedTabs.tsx index 2ae51be8d5..33ac09f823 100644 --- a/packages/client/ui-primitives/src/SegmentedTabs.tsx +++ b/packages/client/ui-primitives/src/SegmentedTabs.tsx @@ -19,14 +19,16 @@ export interface SegmentedTab { * Keyboard selection also moves focus; only the selected tab is a tab stop. * @param props.label - localized accessible name for the tab list. * @param props.className - layout placement; panels remain caller-owned. + * @param props.size - `sm` draws 30px tabs with a 2px inset (34px tall); the default `md` draws 34px tabs with a 4px inset (42px tall). * @returns the tab list, without its panels. */ -export function SegmentedTabs({ items, value, onChange, label, className }: { +export function SegmentedTabs({ items, value, onChange, label, className, size = 'md' }: { items: readonly [SegmentedTab, ...SegmentedTab[]] value: Value onChange: (value: Value) => void label: string className?: string | undefined + size?: 'sm' | 'md' | undefined }): ReactNode { const selectedIndex = items.findIndex(item => item.value === value) @@ -52,6 +54,7 @@ export function SegmentedTabs({ items, value, onChange, la return (
({ items, value, onChange, la