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-14-webworker-preview-profile-parity.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml index 3ae45a7509..b04f873a24 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.i18n.yaml @@ -12,8 +12,8 @@ en: a15c7c13f167e5ac zh: 63bcc2ef60c2f344 /agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/boot-the-same-profile-as-the-served-web-deployment: - en: caf1ca77192dcb20 - zh: d663e2cbb05fe4fc + en: 9b957e37f290abaf + zh: 06a4599c8fe449ad /agent-note-boot-the-preview-worker-from-the-shipped-profile-and-fetch-lazy-chunks-on-demand/decision/node-internal-module-interfaces-in-the-worker: en: 37d8e809eaa21d25 zh: 8c7c31bd6f45c823 diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md index 47b2866402..71509500e5 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.md @@ -23,7 +23,7 @@ Measured: with no bundle enabled, the two compositions differ in only one of 188 The served launch path (apps/cli, app-boot) carries no Preview-specific code; the webworker packages reproduce it: - **packer** - - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from app-boot, the same source the Plugins page uses. + - Package roster = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + the rows named in those bundles' patch files; the bundle packages themselves also enter the image (patches, locale, and icon through the publish view). Both tables are read from `@deepseek-ai/dsh-official-bundles`, the same source the Plugins page uses. - The image carries a manifest-only `node_modules/@deepseek-ai/dsh/package.json` (a verbatim copy of `apps/cli/package.json`) as the installation anchor, and the image manifest names it in `installation`. - The image carries no composed configuration and no `config/cordis.yml`; the image manifest records the `profile` the Worker boots. - The image carries a home patch, `home/cordis.patch.yml`: for each row the runtime's `PLATFORM_ENTRY_CONFIG` names (`session-persistence-jsonl`), the config the template's bundle layers compose with the required fields over it (`compression: none`). A patch replaces a row's whole config, so the composed fields stay; the bundle files read at pack time are the ones in the image. diff --git a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md index 018992e103..5c9b5bf233 100644 --- a/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md +++ b/.agents/notes/implemented/architecture/2026-10-14-webworker-preview-profile-parity.zh.md @@ -23,7 +23,7 @@ Status: implemented 正式启动链路(apps/cli、app-boot)不含 Preview 专用代码;由 webworker 包复现这条链路: - **packer** - - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 app-boot 读,与插件页同一来源。 + - 收包清单 = `PROFILE_TEMPLATES[profile].bundles` + `OPTIONAL_BUNDLES` + 这些 bundle 补丁文件里的行;bundle 包本身也打进镜像(补丁、locale、icon 走 publish view)。两张表从 `@deepseek-ai/dsh-official-bundles` 读,与插件页同一来源。 - 镜像里放一份只含清单的 `node_modules/@deepseek-ai/dsh/package.json`(原样复制 `apps/cli/package.json`),作为安装锚点,镜像 manifest 在 `installation` 里记录它。 - 镜像不带组合好的配置,也没有 `config/cordis.yml`;镜像 manifest 记录 Worker 要启动的 `profile`。 - 镜像带一份 home patch `home/cordis.patch.yml`:对运行时 `PLATFORM_ENTRY_CONFIG` 列出的每一行(`session-persistence-jsonl`),写入模板各 bundle 层合成出的配置,并在其上设置所需字段(`compression: none`)。补丁会整体替换一行的配置,所以保留合成出的字段;打包时读取的 bundle 文件就是镜像里的那些。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml new file mode 100644 index 0000000000..3ebb632746 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-file-drag-plugin-ownership.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-19-file-drag-plugin-ownership.md +/agent-note-file-drag-ownership-outside-the-application-layout: + en: 951575b08912a949 + zh: f33e8e6bcb9240bd +/agent-note-file-drag-ownership-outside-the-application-layout/problem: + en: 557edc1db9bee8d8 + zh: 20f47c78c8b0078b +/agent-note-file-drag-ownership-outside-the-application-layout/decision: + en: 1dfea60901d6e6ae + zh: e4fc534dbe01b0d5 +/agent-note-file-drag-ownership-outside-the-application-layout/alternatives-considered: + en: 571eabab132ce4c1 + zh: b5f8207015aeb3be +/agent-note-file-drag-ownership-outside-the-application-layout/consequences: + en: 56ebb7c25911fd61 + zh: 88120d2af74785ef diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md new file mode 100644 index 0000000000..5d99b5815a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.md @@ -0,0 +1,25 @@ +# Agent Note: File drag ownership outside the application layout + +Status: implemented + +English | [中文](2026-10-19-file-drag-plugin-ownership.zh.md) + +## Problem + +Image dragging spans conversation images, sidebar previews, and composer drop targets. Original files must survive source unmount during a drag, and failures must remain visible after the originating panel closes. An application-root image provider puts feature state in layout and makes independent image entries depend on an undeclared React ancestor. + +## Decision + +The [file-drag plugin](../../../../packages/client/ui-file-drag/README.md) owns generic file preparation, browser transfer tokens, retained original files, desktop bridge selection, and transient failure feedback. Consumers receive preparation and error callbacks through their slot or Factory injection. The plugin registers its feedback through `shell.overlay`; layout declares the generic slot and has no image dependency. The shared image primitive owns gestures and cancellation. Images are the first consumer, while MIME naming and transport accept other files. + +## Alternatives considered + +**Root React provider.** It preserves transfer lifetime but adds image-specific assembly to the layout package and hides dependencies from slot registration. Moving the same wrapper into the renderer would move the feature coupling into another generic owner. + +**State on each image.** It avoids shared assembly but cannot deliver the retained file after the source unmounts or keep a late failure visible after the panel closes. + +**Direct browser File only.** Manual macOS Chrome 154.0.8037.93 drags of PNG and PDF fixtures produced a populated `dataTransfer.files` in trusted `dragstart`, but empty files in trusted same-page and cross-window drops. Finder received filename-only text clippings. These measured failures require the application-local token path; they do not establish behavior in untested browsers or cross-tab delivery. Desktop retains Electron native file dragging. + +## Consequences + +One plugin lifetime governs transfer state, a native source-node end listener, and drop completion. The source listener remains attached after React unmount. Independent render roots can share a transfer without sharing a React ancestor. Custom compositions must load the service and declare the feedback slot; custom image Factory registrations supply the required callbacks. Application-local browser tokens are accepted only over the composer; native Desktop files retain the full-page drop overlay. Plugin disposal removes feedback and clears retained files. Loader-backed browser scenarios cover source removal and delivery, while focused tests cover local resource authorization and cleanup failures. diff --git a/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md new file mode 100644 index 0000000000..0e361717a4 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-file-drag-plugin-ownership.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 应用布局之外的文件拖放归属 + +Status: implemented + +[English](2026-10-19-file-drag-plugin-ownership.md) | 中文 + +## Problem + +图片拖放跨越会话图片、侧栏预览和输入框接收区。拖动期间原始文件必须在源组件卸载后继续保留,发起操作的面板关闭后失败提示也必须可见。应用根部的图片 Provider 把功能状态放进布局,并使独立图片入口依赖未声明的 React 祖先组件。 + +## Decision + +[文件拖放插件](../../../../packages/client/ui-file-drag/README.zh.md) 持有通用文件准备、浏览器传递令牌、原始文件、桌面桥接选择和临时失败提示。消费方通过 slot 或 Factory 注入接收准备及错误回调。插件通过 `shell.overlay` 注册提示,布局只声明通用插槽,不依赖图片功能。共享图片原语负责手势与取消。图片是首个调用方,MIME 命名与传输也接受其他文件。 + +## Alternatives considered + +**根部 React Provider。** 它能保留传递生命周期,但会把图片专属组合放进布局包,并对 slot 注册隐藏依赖。把同一个包裹组件移到渲染器,只会把功能耦合转移到另一个通用模块。 + +**每张图片独立持有状态。** 这能避免共享组合,却不能在源组件卸载后交付保留文件,也不能在面板关闭后继续显示延迟到达的失败提示。 + +**仅直接传递浏览器 File。** 在 macOS Chrome 154.0.8037.93 中手动拖放 PNG 与 PDF 时,可信 `dragstart` 中 `dataTransfer.files` 有文件,同页与跨窗口的可信 drop 却收到空文件列表。Finder 收到仅含文件名的文本摘录。这些实测失败要求保留应用内令牌路径,但不能据此判定未测试浏览器或跨标签页交付的行为。Desktop 保留 Electron 原生文件拖放。 + +## Consequences + +一个插件生命周期管理传递状态、源节点上的原生结束监听器与投放完成。React 卸载后,监听器仍绑定源节点。独立渲染根可以共享传递,而不共享 React 祖先组件。自定义组合必须加载服务并声明反馈插槽,自定义图片 Factory 注册提供所需回调。应用内浏览器令牌仅在输入框范围接受;Desktop 原生文件保留全页投放遮罩。插件卸载会移除提示并清除保留文件。通过 Loader 启动的浏览器场景覆盖源图移除与交付,定向测试覆盖本地资源授权和清理失败。 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 `{label}} + : } {open && src !== null && } @@ -152,12 +153,13 @@ export function MessageImage({ image, load, variant, labels }: { /** Wrapping image group shared by user and assistant history: a lone image * renders large unless its owner requests compact tiles or contained list thumbnails. */ -export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels }: { +export function ImageGallery({ images, load, align, compact = false, thumbnail = false, labels, fileDrag }: { images: readonly MessageImageSpec[] load: ImageLoader align: 'start' | 'end' compact?: boolean thumbnail?: boolean + fileDrag: FileDragActions labels: MessageImageLabels }) { if (images.length === 0) return null @@ -170,6 +172,7 @@ export function ImageGallery({ images, load, align, compact = false, thumbnail = image={image} load={load} variant={variant} + fileDrag={fileDrag} labels={labels} /> ))} diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx index 8035470f69..2c57ce0e74 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.tsx +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.tsx @@ -12,6 +12,7 @@ import { FileCard } from '../FileCard.tsx' import { ImageLightbox } from '@deepseek-ai/dsh-client-ui-primitives' import { attachmentRailLabels, dropOverlayLabels, fileCardLabels, lightboxLabels } from './labels.ts' import { installDocumentDropEvents } from './drop-events.ts' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' import css from './ComposerAttachments.module.css' /** Rail item retaining its browser-owned attachment for callbacks. */ @@ -19,6 +20,11 @@ interface ComposerRailItem extends AttachmentRailItem { attachment: ComposerAttachment } +/** File transfer lookup injected by this attachment implementation. */ +export interface ComposerAttachmentsInjected { + readonly readFileDrag: (transfer: DataTransfer) => File | undefined +} + /** The owner share and locale seat the attachment rail reads from its slot props. */ type ComposerAttachmentsInput = Pick) { const [preview, setPreview] = useState(null) const [dragActive, setDragActive] = useState(false) const dragDepth = useRef(0) @@ -37,8 +43,8 @@ export function ComposerAttachments({ }, [attachments, preview]) useEffect(() => { - return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive) - }, [canAcceptDrop, onAddFiles]) + return installDocumentDropEvents(canAcceptDrop, onAddFiles, dragDepth, setDragActive, readFileDrag) + }, [canAcceptDrop, onAddFiles, readFileDrag]) const railItems = useMemo(() => attachments.map(attachment => ({ id: attachment.id, diff --git a/packages/client/ui-attachment/src/client/MessageImages.tsx b/packages/client/ui-attachment/src/client/MessageImages.tsx index 76a0718c1f..0a95ddc66d 100644 --- a/packages/client/ui-attachment/src/client/MessageImages.tsx +++ b/packages/client/ui-attachment/src/client/MessageImages.tsx @@ -12,11 +12,11 @@ type MessageImagesProps = MessageImagesOwnerProps & PropsRenderFactories * @param props - image sources, authorized loader, layout hints, optional labels, and Factory locale seat. * @returns the official gallery; no Session binding is needed by this component. */ -export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t }: +export function MessageImagesContent({ images, loadImage, align, compact = false, thumbnail = false, labels, t, fileDrag }: FactoryComponentPropsOf<'conversation.images.content'>) { const resolved = useMemo(() => labels ?? messageImageLabels(t), [labels, t]) return + compact={compact} thumbnail={thumbnail} labels={resolved} fileDrag={fileDrag} /> } /** diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index 2f3da69372..9898c714da 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -1,4 +1,5 @@ /** Document drag-and-drop listeners owned by one mounted attachment view. */ +import { FILE_DRAG_TOKEN_TYPE } from '@deepseek-ai/dsh-util-file-drag' import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -25,6 +26,7 @@ function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]): * @param onAddFiles - attachment intake callback. * @param dragDepth - the view's retained nested-drag counter. * @param setDragActive - publish whether a file drag is active. + * @param readFileDrag - resolve an original image retained by this application during a browser drag. * @returns cleanup for exactly these listeners. */ export function installDocumentDropEvents( @@ -32,10 +34,13 @@ export function installDocumentDropEvents( onAddFiles: ComposerAttachmentsProps['onAddFiles'], dragDepth: { current: number }, setDragActive: (active: boolean) => void, + readFileDrag: (transfer: DataTransfer) => File | undefined, ): () => void { const fileTransfer = (event: globalThis.DragEvent): DataTransfer | null => { const dataTransfer = event.dataTransfer - if (dataTransfer === null || !dataTransfer.types.includes('Files')) return null + if (dataTransfer === null || (!dataTransfer.types.includes('Files') && !dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE))) return null + if (dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE) + && (!(event.target instanceof Element) || event.target.closest('[data-composer-card]') === null)) return null return dataTransfer } const reset = (): void => { @@ -43,10 +48,11 @@ export function installDocumentDropEvents( setDragActive(false) } const onDragEnter = (event: globalThis.DragEvent): void => { - if (fileTransfer(event) === null) return + const dataTransfer = fileTransfer(event) + if (dataTransfer === null) return event.preventDefault() dragDepth.current += 1 - setDragActive(true) + if (!dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE)) setDragActive(true) } const onDragOver = (event: globalThis.DragEvent): void => { const dataTransfer = fileTransfer(event) @@ -68,8 +74,9 @@ export function installDocumentDropEvents( event.preventDefault() reset() if (canAcceptDrop) { - const files = [...dataTransfer.files] - onAddFiles(files, droppedDirectories(dataTransfer, files)) + const image = dataTransfer.types.includes(FILE_DRAG_TOKEN_TYPE) ? readFileDrag(dataTransfer) : undefined + const files = image === undefined ? [...dataTransfer.files] : [image] + if (files.length > 0) onAddFiles(files, droppedDirectories(dataTransfer, files)) } } document.addEventListener('dragenter', onDragEnter) diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index 75059f84b3..7ef44e1dd8 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -5,11 +5,12 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-tool/client' import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' -import { ComposerAttachments } from './ComposerAttachments.tsx' +import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client' +import { ComposerAttachments, type ComposerAttachmentsInjected } from './ComposerAttachments.tsx' import { MessageImages, MessageImagesContent } from './MessageImages.tsx' /** Slot registry required by the attachment presentation plugin. */ -export const inject = ['slots'] +export const inject = ['slots', 'fileDrag'] /** * Register composer attachments and the shared gallery with its domain adapters. @@ -18,9 +19,11 @@ export const inject = ['slots'] export function apply(ctx: ClientContext): void { ctx.slots.registerFactory({ name: 'conversation.images.content', scope: 'root', locale: 'conversation', + inject: () => ({ fileDrag: ctx.fileDrag.actions }), }, MessageImagesContent) ctx.slots.inject('conversation.input.attachments', () => ctx.slots.register({ name: 'conversation.input.attachments', + inject: (): ComposerAttachmentsInjected => ({ readFileDrag: ctx.fileDrag.read }), locale: 'conversation', }, ComposerAttachments)) ctx.slots.inject('conversation.chat.message.images', () => ctx.slots.register({ diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index 9b101ea8ab..281c0a57bb 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -85,6 +85,7 @@ function props(overrides: Partial = {}): Paramete uploadFailureText: (reason: string) => `refused:${reason}`, t, ...overrides, + readFileDrag: () => undefined, } } @@ -378,3 +379,28 @@ describe('ComposerAttachments file drafts', () => { expect(view.getByTitle('.env').textContent).toContain('ENV 3B') }) }) + +it('accepts internal file tokens only inside the composer without the full-page overlay', () => { + const file = new File([Uint8Array.of(1, 2)], 'original.pdf', { type: 'application/pdf' }) + const onAddFiles = vi.fn() + const view = render(
transfer.getData('application/x-dsh-file') === 'token' ? file : undefined} />
) + const data = new Map([['application/x-dsh-file', 'token']]) + const transfer = { + types: ['application/x-dsh-file'], items: [], files: [], + getData: (key: string) => data.get(key) ?? '', + } + fireEvent.dragEnter(document.body, { dataTransfer: transfer }) + fireEvent.dragOver(document.body, { dataTransfer: transfer }) + fireEvent.drop(document.body, { dataTransfer: transfer }) + expect(onAddFiles).not.toHaveBeenCalled() + const target = view.getByTestId('composer') + fireEvent.dragEnter(target, { dataTransfer: transfer }) + fireEvent.dragOver(target, { dataTransfer: transfer }) + expect(view.queryByText('文件或图片拖动到此处即可添加')).toBeNull() + fireEvent.drop(target, { dataTransfer: transfer }) + expect(onAddFiles).toHaveBeenCalledExactlyOnceWith([file], new Set()) + data.set('application/x-dsh-file', 'foreign') + fireEvent.drop(target, { dataTransfer: transfer }) + expect(onAddFiles).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx index 04f83f3ba8..09f0db62cb 100644 --- a/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx +++ b/packages/client/ui-attachment/tests/image-slot-isolation.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** Independent domain image positions with one shared official gallery Factory. */ +import * as fileDragPlugin from '@deepseek-ai/dsh-client-ui-file-drag/client' import { act, cleanup, within } from '@testing-library/react' import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' @@ -27,6 +28,7 @@ async function bench() { runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) new UiConversation(runtime.ctx, runtime.sessions) + await runtime.mount(fileDragPlugin) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'image-domains' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('image-domains')) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index d447df5fd9..7d0ee96bea 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -39,7 +39,7 @@ describe('MessageImage', () => { const load = Object.assign(vi.fn(() => new Promise(() => {})), { peek: vi.fn(() => 'blob:seeded'), }) - const view = render() + const view = render() expect(view.queryByText('图片加载中…')).toBeNull() expect((view.getByAltText('history.png') as HTMLImageElement).src).toContain('blob:seeded') expect(load).toHaveBeenCalledWith(attachment) @@ -47,7 +47,7 @@ describe('MessageImage', () => { it('loads a session-authorized URL, bounds the thumbnail, and clicks into the original', async () => { const load = vi.fn().mockResolvedValue('blob:history') - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('style')).toContain('width: 240px') expect(frame.getAttribute('style')).toContain('height: 120px') @@ -62,7 +62,7 @@ describe('MessageImage', () => { it('ignores a click while the thumbnail is still loading', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(view.getByText('图片加载中…')).toBeTruthy() fireEvent.click(frame) @@ -72,7 +72,7 @@ describe('MessageImage', () => { it('falls back to the image label for an unnamed attachment', async () => { const { name: _named, ...unnamed } = attachment const load = vi.fn().mockResolvedValue('blob:unnamed') - const view = render() + const view = render() await waitFor(() => { expect(view.getByAltText('图片')).toBeTruthy() }) expect(view.getByRole('button', { name: '图片,点击查看原图' })).toBeTruthy() }) @@ -82,7 +82,7 @@ describe('MessageImage', () => { .mockRejectedValueOnce(new Error('offline')) .mockRejectedValueOnce(new Error('still offline')) .mockResolvedValueOnce('blob:retry') - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) fireEvent.click(retry) const retryAgain = await view.findByRole('button', { name: '图片加载失败,点击重试' }) @@ -94,7 +94,7 @@ describe('MessageImage', () => { it('clamps extreme aspect ratios and anchors the crop toward the informative edge', async () => { const load = vi.fn().mockResolvedValue('blob:ratio') const tall = render( - , + , ) const tallFrame = tall.getByRole('button', { name: 'history.png,点击查看原图' }) expect(tallFrame.getAttribute('style')).toContain('width: 60px') @@ -103,7 +103,7 @@ describe('MessageImage', () => { expect(tall.getByAltText('history.png').style.objectPosition).toBe('center top') tall.unmount() const wide = render( - , + , ) const wideFrame = wide.getByRole('button', { name: 'history.png,点击查看原图' }) expect(wideFrame.getAttribute('style')).toContain('width: 240px') @@ -112,7 +112,7 @@ describe('MessageImage', () => { expect(wide.getByAltText('history.png').style.objectPosition).toBe('left center') wide.unmount() const small = render( - , + , ) const smallFrame = small.getByRole('button', { name: 'history.png,点击查看原图' }) expect(smallFrame.getAttribute('style')).toContain('width: 100px') @@ -121,7 +121,7 @@ describe('MessageImage', () => { it('renders a tile at the fixed square without inline sizing', () => { const load = vi.fn(() => new Promise(() => {})) - const view = render() + const view = render() const frame = view.getByRole('button', { name: 'history.png,点击查看原图' }) expect(frame.getAttribute('data-variant')).toBe('tile') expect(frame.getAttribute('style')).toBeNull() @@ -129,7 +129,7 @@ describe('MessageImage', () => { it.each(['tile', 'thumbnail'] as const)('keeps the %s variant on the failed-load retry control', async (variant) => { const load = vi.fn().mockRejectedValue(new Error('offline')) - const view = render() + const view = render() const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) expect(retry.getAttribute('data-variant')).toBe(variant) }) @@ -140,7 +140,7 @@ describe('MessageImage', () => { ] as const)('labels thumbnail status icons and retries a failed read in %s', async (_locale, statusLabels) => { const pending = Promise.withResolvers() const load = vi.fn().mockReturnValueOnce(pending.promise).mockResolvedValue('blob:retry') - const view = render() + const view = render() const loading = view.getByRole('button', { name: statusLabels.loading }) expect(loading.getAttribute('title')).toBe(statusLabels.loading) expect(loading.getAttribute('aria-busy')).toBe('true') @@ -164,7 +164,7 @@ describe('MessageImage', () => { it('uses the owner display name for an unnamed thumbnail and its original-image viewer', async () => { const { name: _name, ...unnamed } = attachment const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue('blob:thumbnail') - const view = render() + const view = render() fireEvent.click(await view.findByRole('button', { name: labels.loadFailed })) const image = await view.findByAltText('Image 2') expect(image.closest('button')?.getAttribute('data-variant')).toBe('thumbnail') @@ -182,13 +182,13 @@ describe('MessageImage', () => { it('ignores a load settling after unmount', async () => { let resolve: ((url: string) => void) | undefined const load = vi.fn(() => new Promise((r) => { resolve = r })) - const view = render() + const view = render() view.unmount() resolve?.('blob:late') await Promise.resolve() let reject: ((error: Error) => void) | undefined const failing = vi.fn(() => new Promise((_r, rej) => { reject = rej })) - const second = render() + const second = render() second.unmount() reject?.(new Error('late failure')) await Promise.resolve() @@ -199,7 +199,7 @@ describe('MessageImage preview arm', () => { it('displays a local preview immediately, without the loader, sized by its probed dimensions', () => { const load = vi.fn() const view = render( - { it('sizes an unprobed lone preview as a square crop and falls back to the image label', () => { const load = vi.fn() const view = render( - , + , ) const img = view.getByAltText('图片') as HTMLImageElement const frame = img.closest('button') as HTMLButtonElement @@ -227,7 +227,7 @@ describe('MessageImage preview arm', () => { it('opens the lightbox from a preview thumbnail', () => { const view = render( - '')} variant="tile" labels={labels} />, + '')} variant="tile" labels={labels} />, ) fireEvent.click(view.getByRole('button', { name: '图片,点击查看原图' })) expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() @@ -237,10 +237,10 @@ describe('MessageImage preview arm', () => { describe('ImageGallery', () => { it('renders nothing without images and an aligned wrapping group with them', async () => { const load = vi.fn().mockResolvedValue('blob:gallery') - const empty = render() + const empty = render() expect(empty.container.firstChild).toBeNull() const view = render( - { it('renders a lone image large and compact or grouped images as square tiles', () => { const load = vi.fn(() => new Promise(() => {})) - const lone = render() + const lone = render() expect(lone.container.querySelectorAll('[data-variant="single"]')).toHaveLength(1) lone.unmount() const compact = render( - , + , ) expect(compact.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(1) compact.unmount() const several = render( - , + , ) expect(several.container.querySelectorAll('[data-variant="tile"]')).toHaveLength(3) }) @@ -276,6 +276,7 @@ describe('ImageGallery', () => { locale.setLocale('en') runtime.ctx.provide('locale', locale) runtime.slots.installLocale(locale) + runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) await runtime.mount(attachmentPlugin) const images = [{ preview: { url: 'blob:labels', name: 'preview.png' } }] const loadImage = vi.fn(async () => 'blob:unused') @@ -316,6 +317,7 @@ describe('ImageGallery', () => { new UiConversation(runtime.ctx, runtime.sessions) runtime.slots.installLocale(locale) await runtime.declare({ 'conversation.chat.message.images': { kind: 'single', scope: 'session' } }) + runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) await runtime.mount(attachmentPlugin) await runtime.sessions.add({ id: 'message-images-test' }) const session = runtime.sessions.retainFor(runtime.ctx, SessionId('message-images-test')) @@ -342,3 +344,5 @@ describe('ImageGallery', () => { expect(loadImage).toHaveBeenCalledOnce() }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 6181d8db35..a14099543e 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -33,6 +33,7 @@ async function bench() { 'tool.call.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) + ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) const registerFactory = vi.spyOn(ctx.slots, 'registerFactory') const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -46,10 +47,12 @@ describe('attachment plugin', () => { it('registers all entries and removes them with the plugin fiber', async () => { const { ctx, fiber, registerFactory } = await bench() - expect(inject).toEqual(['slots']) - expect(registerFactory).toHaveBeenCalledWith({ + expect(inject).toEqual(['slots', 'fileDrag']) + expect(registerFactory.mock.calls[0]?.[0]).toMatchObject({ name: 'conversation.images.content', scope: 'root', locale: 'conversation', - }, MessageImagesContent) + }) + expect(typeof registerFactory.mock.calls[0]?.[0].inject).toBe('function') + expect(registerFactory.mock.calls[0]?.[1]).toBe(MessageImagesContent) expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(1) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ locale: 'conversation', component: ComposerAttachments, @@ -63,3 +66,5 @@ describe('attachment plugin', () => { expect(ctx.slots.snapshot('factory:conversation.images.content')).toHaveLength(0) }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 136e1ebb26..1b7d6e0129 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -37,6 +37,12 @@ }, { "path": "../locale/tsconfig.client.json" + }, + { + "path": "../ui-file-drag" + }, + { + "path": "../../util/file-drag" } ] } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index b73a302dba..90f3b7073a 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -15,11 +15,11 @@ en: 41cd21266011d571 zh: 3172d1a2acbb27b7 /deepseek-ai-dsh-client-ui-chat/reference-previews: - en: aa6b1abec265bf1b - zh: 7260ce78afba2584 + en: a05d85ff4949b526 + zh: 7261ffc98665e9e7 /deepseek-ai-dsh-client-ui-chat/hidden-chat-rows: - en: cbf1f0a2205046a5 - zh: 2e59d0815351349f + en: e60db44c8a6a016e + zh: d563bd5d34da9807 /deepseek-ai-dsh-client-ui-chat/command-and-failure-rows: en: 41dcd70e14ebe9cc zh: ededead572b59037 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 98b1aa5ec9..33a255b66a 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -33,6 +33,8 @@ File-mention providers receive the viewed Session ID with the closing-turn owner ## Reference previews +Image drag actions arrive through the image entry’s inject face from [ui-file-drag](../ui-file-drag/README.md). + Sent generic-file attachments retain the draft's [file-card radius](../../../docs/ui-radius.md#radius-scale). `ChatView` supplies file and HTTP(S) navigation through the existing `MarkdownDelegateProvider` around its node list, passing `requestOpenFile`, `openExternalLink`, and `fileImages` directly. Its file opener retains Chat's failure feedback and retry handling. Assistant Markdown file links open in the right Sidebar after the message settles, including references to unmodified files. Relative paths resolve in the viewed Session's workspace; absolute paths retain the same Session's filesystem access. `#L24` and `#L24-L30` navigate to the first specified line and reuse an existing file tab. Missing files show the preview's error state. @@ -54,6 +56,8 @@ Chat omits system-prompt, ordinary Context injection, and `permission` command r When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start. +Chat hides top-level `dsh-` directive fences, as found by [`dsh-util-action-block`](../../util/action-block/README.md), from rendered answers and from the answer text the Turn footer copies; Trajectory keeps the raw text. + ## Command and failure rows diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 88fa3d4e5f..18cf0f910f 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -33,6 +33,8 @@ kind: "package-reference" ## 引用预览 +图片拖放回调通过图片入口的注入接口从 [ui-file-drag](../ui-file-drag/README.zh.md) 获取。 + 已发送的普通文件附件保留草稿中的[文件卡片圆角](../../../docs/ui-radius.zh.md#radius-scale)。 `ChatView` 通过节点列表外围既有的 `MarkdownDelegateProvider` 提供文件及 HTTP(S) 导航,直接传入 `requestOpenFile`、`openExternalLink` 和 `fileImages`。文件打开动作保留 Chat 的失败反馈与重试处理。Assistant Markdown 文件链接在消息落定后可于右侧栏打开,包括未修改文件的引用。相对路径基于当前查看的 Session 工作区解析;绝对路径仍使用同一 Session 的文件系统访问。`#L24` 和 `#L24-L30` 定位到指定起始行,并复用现有文件标签。文件缺失时显示预览错误状态。 @@ -54,6 +56,8 @@ Chat 在所有工作步骤展示模式下都不显示系统提示词行、普通 Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。 +Chat 在渲染的回答和轮次页脚复制的回答文本中隐藏 [`dsh-util-action-block`](../../util/action-block/README.zh.md) 识别出的顶层 `dsh-` 指令围栏;Trajectory 保留原始文本。 + ## 指令与失败行 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 0f31c600be..440638b435 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -41,7 +41,8 @@ "@deepseek-ai/dsh-client-ui-session", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-sidebar-right", - "@deepseek-ai/dsh-client-ui-workspace" + "@deepseek-ai/dsh-client-ui-workspace", + "@deepseek-ai/dsh-client-ui-file-drag" ], "platform": "web" } @@ -91,11 +92,13 @@ "@deepseek-ai/dsh-util-workspace-path": "workspace:*", "@deepseek-ai/dsh-tool-executor-ptc": "workspace:*", "@deepseek-ai/dsh-util-values": "workspace:*", + "@deepseek-ai/dsh-util-action-block": "workspace:*", "@tanstack/react-virtual": "3.14.10", "react": "^18.2.0", "react-dom": "^18.2.0", "@deepseek-ai/dsh-settings": "workspace:*", - "@deepseek-ai/dsh-client-product-analytics": "workspace:*" + "@deepseek-ai/dsh-client-product-analytics": "workspace:*", + "@deepseek-ai/dsh-client-ui-file-drag": "workspace:*" }, "dependencies": { "@deepseek-ai/schemastery": "workspace:~" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index c31a219598..fcbc44d9be 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -1,4 +1,5 @@ /** Register the Chat Conversation target, renderers, stats, and details surface. */ +import type {} from '@deepseek-ai/dsh-client-ui-file-drag/client' import type {} from '@deepseek-ai/dsh-client-product-analytics/client' import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' @@ -75,7 +76,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ 'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale', - 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', + 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', 'fileDrag', ] /** @@ -249,6 +250,7 @@ export function apply(ctx: Context): void { const chat = chatSource(binding) return { ...nodeSources(binding), + fileDrag: ctx.fileDrag.actions, hooks: { presentation, interactionIds: ctx.uiConversation.interactions }, dispatchInteraction: gesture => ctx.uiConversation.interactions.dispatch(sessionId, gesture), fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index cd5f9c00d2..455053efe9 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -86,7 +86,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ rendered.push( s.order) const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) @@ -85,12 +85,14 @@ export function ChatView({ // Workspace root off the session list row: path summaries display relative to it. const cwd = useSessions(s => s.byId[sessionId]?.cwd) const fileImages = useMemo(() => ({ + fileDrag, resolve: (path: string) => fileMediaUrl(document.baseURI, resolveWorkspacePath(cwd, path)), + resourceAddress: (path: string) => sessionFileAddress(sessionId, resolveWorkspacePath(cwd, path)), labels: { open: t('image.open'), loading: t('image.loading'), failed: t('image.failed'), dialog: t('image.dialog'), close: t('image.close'), }, - }), [cwd, t]) + }), [cwd, t, fileDrag, sessionId]) const running = useSession(s => s.running) // Fold transitions run while a Turn is live, while an input is pending, and briefly after settling. const [motionTail, setMotionTail] = useState(false) diff --git a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx index c29b831ce8..d901a70566 100644 --- a/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx +++ b/packages/client/ui-chat/src/client/chat/TurnTailNodeView.tsx @@ -4,6 +4,7 @@ import type { ChatNodeViewProps, PerformanceUsageInjected, TurnTailOwnerProps } import { MessageIconActions } from './MessageIconActions.tsx' import { TurnUsagePanel } from './TurnUsagePanel.tsx' import { assistantText } from './turn-assistant.ts' +import { hideDirectiveFences } from './directive-fences.ts' import { hasAssistantReplyContent } from '../contract/assistant-content.ts' import type { ChatNode } from '../contract/chat-nodes.ts' import type { ChatSnapshot } from '../contract/snapshot.ts' @@ -62,7 +63,7 @@ export const TurnTailNodeView = memo(function TurnTailNodeView({ > {tail} string | null hooks: { diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index c89f2c88b3..557b9e2a04 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -62,6 +62,7 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true, } as never) const layout = { closeRightbar: vi.fn(), openRightbar: vi.fn() } runtime.ctx.provide('connection', { state: createSnapshotStore('connected') } as never) + runtime.ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) runtime.ctx.provide('layout', layout as never) const sidebarRight = { openResource: vi.fn<(address: string) => void>(), @@ -535,3 +536,5 @@ describe('Chat inject API', () => { await b.runtime.dispose() }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx index f5f070f6f4..ab256ad15b 100644 --- a/packages/client/ui-chat/tests/chat-apply.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-apply.client.spec.tsx @@ -44,6 +44,7 @@ const SID = 'session-1' as SessionId async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag']) const chatSettings = stubConfigForm() runtime.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) }, diff --git a/packages/client/ui-chat/tests/chat-scroll-overflow.client.spec.ts b/packages/client/ui-chat/tests/chat-scroll-overflow.client.spec.ts new file mode 100644 index 0000000000..875c6e91cf --- /dev/null +++ b/packages/client/ui-chat/tests/chat-scroll-overflow.client.spec.ts @@ -0,0 +1,24 @@ +/** The transcript scroller never exposes horizontal scrollable overflow. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const read = (name: string): string => + readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8') + +const rule = (css: string, selector: string): string => { + const start = css.indexOf(`${selector} {`) + return css.slice(start, css.indexOf('}', start) + 1) +} + +describe('Transcript scroll overflow', () => { + it('clips the horizontal axis in both scrollport modes', () => { + // The text-interaction surface is absolutely positioned inside the message + // column and can reach past its right edge; an `auto` horizontal axis turns + // that into a scroll range whose appearance and resolution jitter the + // transcript while the tail is pinned. + const css = read('ChatView.module.css') + expect(rule(css, '.scroll')).toMatch(/overflow-x:\s*clip/) + expect(rule(css, ':global([data-conversation-scroll]) .scroll')).toMatch(/overflow-x:\s*clip/) + }) +}) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index f1840034d4..1cef672d00 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -468,6 +468,7 @@ function makeHarness( const useConnectionState = bindSnapshotSelector(connection) const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children} const props: HarnessStandardProps = { + fileDrag, renderFactorySlot, usePanelInfo: selector => selector({ activePanelId: null }), sessionId: SID, @@ -2542,6 +2543,17 @@ describe('ChatView', () => { expect(branchButtons.map(button => button.getAttribute('aria-disabled'))).toEqual([null, null]) }) + it('copies the final answer without its dsh- directive blocks', () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```' + const h = makeHarness({ nodes: [user(1, 'hi'), assistant(2, `方案在下方。\n\n${block}`)], turnEnds: new Map([[1, 2]]) }) + const view = render() + const tail = view.container.querySelector('[data-turn-tail]') + fireEvent.click(within(tail!).getByRole('button', { name: '复制' })) + expect(writeText).toHaveBeenCalledWith('方案在下方。') + }) + it('keeps every Assistant reply visible while the whole Turn folds its process', () => { const progress = { ...assistant(2, 'stage reply', 1, 1), @@ -4609,3 +4621,5 @@ describe('ChatView', () => { expect(rows).toEqual(['fixture:steering:3']) }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-chat/tests/directive-fences.client.spec.ts b/packages/client/ui-chat/tests/directive-fences.client.spec.ts index b6c31957e3..3d6d253fde 100644 --- a/packages/client/ui-chat/tests/directive-fences.client.spec.ts +++ b/packages/client/ui-chat/tests/directive-fences.client.spec.ts @@ -1,13 +1,33 @@ import { expect, it } from 'vitest' import { hideDirectiveFences } from '../src/client/chat/directive-fences.ts' -it('hides dsh- fences, closed or still streaming, and keeps prose and other fences', () => { - const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```' +const block = '```dsh-action\n{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}\n```' + +it('hides closed top-level dsh- fences and trims the edges they leave', () => { expect(hideDirectiveFences(`方案在下方。\n\n${block}`)).toBe('方案在下方。') expect(hideDirectiveFences(`${block}\n\n后记`)).toBe('后记') - expect(hideDirectiveFences('说明\n\n```dsh-action\n{"action":"create_pers')).toBe('说明') + expect(hideDirectiveFences(`前\n\n${block}\n\n后`)).toBe('前\n\n后') expect(hideDirectiveFences('```dsh-action json\n{}\n``` \n尾')).toBe('尾') - const code = '看代码:\n\n```ts\nconst a = 1\n```\n\n完' +}) + +it('keeps an unclosed fence in a finished answer and hides it while streaming', () => { + const unclosed = '说明\n\n```dsh-action\n{"action":"create_pers' + expect(hideDirectiveFences(unclosed)).toBe(unclosed) + expect(hideDirectiveFences(unclosed, { streaming: false })).toBe(unclosed) + expect(hideDirectiveFences(unclosed, { streaming: true })).toBe('说明') +}) + +it('follows CommonMark fences for nested examples, longer fences and fence characters after text', () => { + const nested = '示例:\n\n````markdown\n' + block + '\n````' + expect(hideDirectiveFences(nested)).toBe(nested) + expect(hideDirectiveFences('前\n\n````dsh-action\n{"note":"```"}\n```\n````\n\n后')).toBe('前\n\n后') + const glued = '见下方:```dsh-action\n{}\n```' + expect(hideDirectiveFences(glued)).toBe(glued) +}) + +it('keeps other text as written, including blank lines inside other code blocks', () => { + const code = '看代码:\n\n```ts\nconst a = 1\n\n\n\nconst b = 2\n```\n\n完' expect(hideDirectiveFences(code)).toBe(code) - expect(hideDirectiveFences('没有围栏')).toBe('没有围栏') + expect(hideDirectiveFences(`${code}\n\n${block}`)).toBe(code) + expect(hideDirectiveFences('\n没有围栏 \n')).toBe('\n没有围栏 \n') }) diff --git a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx index e6992183a5..c7f67f3505 100644 --- a/packages/client/ui-chat/tests/quota-notice.client.spec.tsx +++ b/packages/client/ui-chat/tests/quota-notice.client.spec.tsx @@ -46,6 +46,7 @@ const quotaEntry = (seq: number, code: string): SessionLiveEventEntry => ({ */ async function bench() { const runtime = await SlotTestRuntime.create() + runtime.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag']) disposers.push(() => runtime.dispose()) const chatSettings = stubConfigForm() runtime.ctx.provide('configForms', { diff --git a/packages/client/ui-chat/tsconfig.client.json b/packages/client/ui-chat/tsconfig.client.json index 9b241459b1..3ffdd2ce1a 100644 --- a/packages/client/ui-chat/tsconfig.client.json +++ b/packages/client/ui-chat/tsconfig.client.json @@ -15,6 +15,9 @@ { "path": "../../util/values" }, + { + "path": "../../util/action-block" + }, { "path": "../../../vendor/cordis" }, @@ -117,6 +120,9 @@ { "path": "../product-analytics/tsconfig.client.json" }, + { + "path": "../ui-file-drag" + }, { "path": "../../core/tool-executor-ptc" } diff --git a/packages/client/ui-commands/README.i18n.yaml b/packages/client/ui-commands/README.i18n.yaml index 2521e4cb81..eed1e982c7 100644 --- a/packages/client/ui-commands/README.i18n.yaml +++ b/packages/client/ui-commands/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 09388d293f9be9cb /deepseek-ai-dsh-client-ui-commands/use-this-package: - en: 01adce72791579fa - zh: 70eb55a1529038e7 + en: c325c9eaca4efe47 + zh: dbbc29cc9cec54bc /deepseek-ai-dsh-client-ui-commands/use-this-package/kinds-and-decorations: en: 57679a20574d8b3f zh: 98d5e5a7781e6de1 diff --git a/packages/client/ui-commands/README.md b/packages/client/ui-commands/README.md index fc5e7efe21..ef8781649e 100644 --- a/packages/client/ui-commands/README.md +++ b/packages/client/ui-commands/README.md @@ -25,7 +25,7 @@ Typing a `/` command opens a registered popup, a client action, a host command's ## Use this package -Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup holds composer focus: typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it. +Mount this plugin alongside `ui-input-trigger` and `ui-conversation`; the `/` source then appears in the trigger menu, and business packages register their command surfaces through `ctx.commandUi`. Typing `/model` opens the registered popup; a host command with an argument claim opens its input or executes directly. The popup automatically focuses its search input on desktop. Phones and tablets require tapping the search input, following the shared [text-input focus policy](../ui-primitives/README.md#use-this-package). With search focused, typing filters the loaded rows locally, `↑`/`↓` walk them, Enter and `Tab` accept the highlighted row, and Escape and `Shift+Tab` return to the composer. The highlight opens on the row the options mark as the session's current value, so accepting on a freshly opened panel confirms it. The composer's `+` button and a typed `/` open the same menu: an Add section (File, Goal, Plan, Feedback) and a Commands section (Compact, Permission, Model, Export) in usage order, each row with a glyph, a localized title and description, and the command name as an alias where the localized title differs from it. ### Kinds and decorations diff --git a/packages/client/ui-commands/README.zh.md b/packages/client/ui-commands/README.zh.md index 7393d20329..7ad5b4f9a1 100644 --- a/packages/client/ui-commands/README.zh.md +++ b/packages/client/ui-commands/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用本包 -与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。弹窗持有 composer 焦点:键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。 +与 `ui-input-trigger` 及 `ui-conversation` 一起挂载本插件;`/` source 随即出现在触发菜单中,业务包经 `ctx.commandUi` 注册自己的命令表面。键入 `/model` 打开已注册的弹窗;带参数声明的宿主命令打开其输入或直接执行。桌面端的弹窗自动聚焦搜索框。手机和平板需要点击搜索框,遵循共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。搜索框聚焦后,键入即在已加载的行上本地筛选,`↑`/`↓` 在行间移动,回车与 `Tab` 接受高亮行,Escape 与 `Shift+Tab` 把焦点还给 composer。高亮落在选项标记为会话当前值的行上,因此在刚打开的弹窗上接受即确认当前值。composer 的 `+` 按钮与键入的 `/` 打开同一个菜单:「添加」小节(文件、目标、计划、反馈)与「指令」小节(压缩、权限、模型、下载日志)按使用频次排列,每行带图标、本地化的标题与说明,本地化标题与命令名不同时还显示命令名作为别名。 ### 种类与装饰 diff --git a/packages/client/ui-commands/src/client/PopupSelectView.tsx b/packages/client/ui-commands/src/client/PopupSelectView.tsx index 4b0b4bf418..911a8f5d21 100644 --- a/packages/client/ui-commands/src/client/PopupSelectView.tsx +++ b/packages/client/ui-commands/src/client/PopupSelectView.tsx @@ -1,9 +1,10 @@ /** * Official popupSelect shell: renders one session's PopupSelectController * store into the conversation.input.overlay anchor. Unlike the slash menu - * (combobox — textarea keeps focus), this shell HOLDS focus while open: the - * inner search input takes focus, plain typing filters the loaded options - * locally, Enter and Tab accept the filtered highlight, ↑↓ walk it (wrapping, + * (combobox — textarea keeps focus), this shell focuses its search input on + * desktop; phones and tablets require explicit search focus. Typing filters + * the loaded options locally, Enter and Tab accept the filtered highlight, + * ↑↓ walk it (wrapping, * scrolled into view), and Escape and Shift+Tab dismiss back to the composer. * ←→ keep the search input's native caret. Any pointer interaction outside the * box dismisses (the click's own target takes focus). Closed state renders @@ -14,7 +15,7 @@ import { MenuGroup, MenuSurface, observeStickyMenuGroups } from '@deepseek-ai/ds import { Fragment, useEffect, useMemo, useRef } from 'react' import { useSyncExternalStore } from 'react' import clsx from 'clsx' -import { IconCheckOutlineRegular, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconCheckOutlineRegular, isMobileDevice, RiskConfirmation, useAnchoredMaxHeight } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import { filterOptions } from './popup.ts' import type { SelectOption } from './contract.ts' @@ -67,8 +68,7 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { cardRef.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }) }, [active, rows, state.confirming]) - // Focus ownership: the search input grabs on open, and ANY outside - // pointer interaction dismisses — + // Any outside pointer interaction dismisses — // capture phase so a click landing anywhere else (textarea included) // closes the shell before its own handlers run; that click's target then // takes focus naturally, so no focusComposer here. @@ -84,7 +84,9 @@ export function PopupSelectView({ popup, t }: PopupSelectViewProps) { // Focus the search input after it mounts (separate effect so the ref is populated). useEffect(() => { - if (state.open && state.confirming === null) searchRef.current?.focus() + if (!state.open || state.confirming !== null) return + if (isMobileDevice()) return + searchRef.current?.focus() }, [state.open, state.confirming]) if (!state.open) return null diff --git a/packages/client/ui-commands/tests/popup-view.client.spec.tsx b/packages/client/ui-commands/tests/popup-view.client.spec.tsx index 3003a868d9..9b1d246927 100644 --- a/packages/client/ui-commands/tests/popup-view.client.spec.tsx +++ b/packages/client/ui-commands/tests/popup-view.client.spec.tsx @@ -1,7 +1,8 @@ // @vitest-environment jsdom /** * PopupSelectView interaction spec: the search input takes - * focus on open and plain typing filters locally, ↑↓ move the filtered + * focus on desktop open; mobile search requires explicit focus. Typing filters + * locally, ↑↓ move the filtered * highlight while ←→ stay native to the input, Enter and Tab select * single-flight, Escape and Shift+Tab dismiss back through focusComposer, * outside pointerdown dismisses @@ -9,7 +10,8 @@ * retry button, the highlighted row scrolls into view, and the card height * clamps to the space above the composer. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import * as uiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' +import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import type { SelectOption } from '../src/client/contract.ts' import type { PopupSpec, TokenSegment } from '../src/client/popup.ts' @@ -140,6 +142,40 @@ describe('PopupSelectView', () => { expect(rowLabels()).toEqual(['Dark', 'Light', 'Sepia']) }) + it.each([ + { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false }, + { name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false }, + { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false }, + { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false }, + { name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true }, + { name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true }, + ])('automatic search focus respects device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const { popup, search } = await mountOpen() + expect(document.activeElement === search).toBe(automatic) + act(() => { search.focus() }) + fireEvent.change(search, { target: { value: 'dark' } }) + expect(document.activeElement).toBe(search) + expect(rowLabels()).toEqual(['Dark']) + act(() => { popup.dismiss() }) + await act(async () => { + popup.open('theme', spec(), 'ctx-A', SEGMENT) + await Promise.resolve() + }) + expect(document.activeElement === screen.getByRole('textbox', { name: '筛选选项' })).toBe(automatic) + }) + it('renders an optional option badge as a superscript marker', async () => { await mountOpen({ options: () => Promise.resolve([{ id: 'auto', label: 'Auto review', badge: 'EXP' }]), diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 268b7e59ae..1ba26214ba 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -21,8 +21,8 @@ en: 7871f9fe6bcc4459 zh: 0d51565c600b3958 /deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props: - en: 1d70d520d4a7537c - zh: 83febffaf90e0ac5 + en: 6ceacacf8770eec9 + zh: 4864c23171b99a82 /deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries: en: bb1244310f5c0382 zh: 824b84077432b559 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a905984576..4bcc57e1cd 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -70,10 +70,14 @@ The existing semantic `DraftSnapshot` retains quote text and clipboard-coordinat The shared image slot props keep display choices separate from durable references: `thumbnail` requests a contained attachment-list thumbnail, while `compact` requests a cropped gallery tile. An optional per-image `label` supplies the accessible display name; loading and cache identity still use the original attachment reference. `MessageImagesContentInput` adds optional complete `MessageImageLabels` for the root-scoped `conversation.images.content` Factory. Message, tool, and Factory inputs carry the view's authorized `loadImage` callback explicitly; loading and URL caching remain with the resource owner. [ui-attachment](../ui-attachment/README.md) provides this Factory and owns rendering and the lightbox. +The composer uses a minimum 16px draft font on coarse-pointer devices to avoid focus-triggered browser zoom; larger font preferences still apply. + The composer measures its expanded control groups after size, content, visibility, or font-loading changes. If they cannot share a line, the row sets `--dsh-composer-model-text-display: none` and `--dsh-composer-model-icon-display: block` for the model seat; their defaults are `block` and `none`. Wrapping remains available if even the icon cannot fit. The context meter follows the [statistics-control radius](../../../docs/ui-radius.md#radius-scale). +The composer uses the shared [text-input focus policy](../ui-primitives/README.md#use-this-package) on mount, Session switching, and unlock. Identified phones and tablets require tapping the input; desktop devices retain automatic focus. On these mobile devices, a submission that empties the focused draft moves focus to the non-text composer card, dismissing the keyboard while retaining its navigation position. Rejected submissions, retained newer draft text, and another control already holding focus preserve focus. The [Lexical patch](../../../patches/lexical@0.49.0.patch) keeps caret scrolling in the same coordinate system as DOM geometry when a mobile keyboard pans the page. + The context-occupancy button shows a ring and percentage below the input card, after the Session statistics. Clicking it opens the token breakdown in a panel kept inside the viewport, including when no statistics are shown; the button stays hidden until context usage and capacity are available. With Coding Tools off, the shell hides only Trajectory; other plugin-contributed Views remain available. The View tab bar is hidden when fewer than two Views are available. Turning Coding Tools off while Trajectory is active returns to Chat; stored View preferences and recorded Session data remain intact. Turning it on makes Trajectory available again. View owners receive the available roster so their navigation actions can follow the same visibility. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index feb91d7562..57c499aaf8 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -70,10 +70,14 @@ Queue 预览启用空白折叠和文档边缘 trim,保留 emoji,并使用 CS 共享图片插槽属性将展示选择与持久化引用分开:`thumbnail` 请求完整缩放的附件列表缩略图,`compact` 请求裁剪的图片方块。每张图片可通过可选的 `label` 提供无障碍展示名称;加载和缓存标识仍使用原始附件引用。`MessageImagesContentInput` 为 root 作用域的 `conversation.images.content` Factory 增加可选的完整 `MessageImageLabels`。消息、工具及 Factory 输入显式携带视图提供的授权 `loadImage` 回调;读取和 URL 缓存仍由资源所有者负责。[ui-attachment](../ui-attachment/README.zh.md) 提供该 Factory,负责渲染与灯箱。 +在粗指针设备上,composer 的草稿字号至少为 16px,以避免获得焦点时浏览器自动放大;更大的字号偏好仍然生效。 + 控件组的尺寸、内容、可见性或字体加载状态变化后,composer 测量展开状态下的控件组。若无法排在同一行,控制栏为模型位设置 `--dsh-composer-model-text-display: none` 和 `--dsh-composer-model-icon-display: block`;两者默认值分别为 `block` 和 `none`。若连图标也放不下,仍允许换行。 上下文占用率入口遵循[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。 +composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输入框聚焦策略](../ui-primitives/README.zh.md#use-this-package)。识别出的手机和平板需要点击输入框;桌面设备保留自动聚焦。在这些移动设备上,提交清空正在聚焦的草稿后,焦点移到不接受文字输入的 composer 卡片,收起键盘并保留键盘导航位置。被拒绝的提交、保留的新草稿文字,以及已经聚焦的其他控件均保留原有焦点。移动端键盘平移页面时,[Lexical 补丁](../../../patches/lexical@0.49.0.patch)让光标滚动计算与 DOM 几何使用同一坐标系。 + 上下文占用按钮在输入卡片下方、会话统计右侧显示圆环和百分比。点击按钮可在视口内的面板查看 token 构成,没有统计项时面板也不会越界;上下文用量和容量尚不可用时,按钮保持隐藏。 关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 6f638b2371..f8d86187d3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -204,6 +204,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.images.content': { scope: 'root' props: MessageImagesContentInput + inject: { readonly fileDrag: import('@deepseek-ai/dsh-client-ui-primitives').FileDragActions } locale: 'conversation' } } diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 698ef7bda6..f162943b6f 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -62,6 +62,9 @@ function $appendParagraphs(text: string): void { /** Undo merge window for contiguous typing, in ms (the old machine's mergeWindowMs). */ const HISTORY_MERGE_DELAY_MS = 1000 +/** Editor update that removes submitted content while retaining any newer draft suffix. */ +export const DRAFT_SUBMITTED_TAG = 'dsh-draft-submitted' + /** One model-owned editor; registration and disposal remain with its model. */ export class DraftEditorRuntime { /** The editor bound by the Composer's contenteditable host. */ @@ -412,7 +415,7 @@ export class DraftEditorRuntime { const root = $getRoot() root.clear() root.selectEnd() - }, { discrete: true, tag: HISTORY_MERGE_TAG }) + }, { discrete: true, tag: [HISTORY_MERGE_TAG, DRAFT_SUBMITTED_TAG] }) } /** diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 524c8a37ea..9018592b8b 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -1,10 +1,31 @@ /** DOM and keymap bindings installed by the InputBar's existing effects. */ import type { MouseEvent, MutableRefObject, RefObject } from 'react' import type { LexicalEditor } from 'lexical' +import { $getRoot } from 'lexical' +import { focusWithoutRing, isMobileDevice } from '@deepseek-ai/dsh-client-ui-primitives' import type { ComposerKeyboard } from '../../contract/draft-editor.ts' import type { BusyEnterBehavior } from '../../contract/composer-submission.ts' import { resolveSubmitMode } from '../submission-policy.ts' import { registerComposerKeymap } from './keymap.ts' +import { DRAFT_SUBMITTED_TAG } from './runtime.ts' + +/** + * Transfer mobile editor focus after a submission empties the draft. + * A control the user already focused retains focus. + * @param editor - the Session-owned editor. + * @param destination - stable, focusable non-text element that retains the composer's keyboard position. + * @returns the update-listener cleanup. + */ +export function installDraftKeyboardDismissal(editor: LexicalEditor, destination: RefObject): () => void { + return editor.registerUpdateListener(({ editorState, tags }) => { + if (!tags.has(DRAFT_SUBMITTED_TAG) || !isMobileDevice()) return + const input = editor.getRootElement() + if (input === null || input.ownerDocument.activeElement !== input) return + if (editorState.read(() => $getRoot().getTextContentSize() === 0) && destination.current !== null) { + focusWithoutRing(destination.current, { preventScroll: true }) + } + }) +} interface DraftViewGate { locked: boolean diff --git a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx index 40df87269b..b46db926da 100644 --- a/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/EmptyHero.tsx @@ -138,12 +138,13 @@ export function HeroShell({ t, renderSlot, children }: HeroShellProps) { {/* figma 34:10412: fish 34×25 leading the headline, gap 10. */} { - 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 e53e666864..80d04e9b80 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' @@ -178,13 +178,11 @@ export const InputBar = memo(function InputBar({ revealDraftSelection(scrollRef) } - // Unlock (mount / session switch) returns focus to the box, and owns the - // reveal that comes with it. Lexical's focus() suppresses the browser's - // scroll walk (preventScroll inside), so the reveal in our own scrollport - // is ours to perform — switching to a longer draft otherwise leaves the - // caret (restored at the draft's end) off screen. + // Automatic desktop focus restores and reveals the draft selection without + // scrolling the conversation. useEffect(() => { if (locked || editor === null) return + if (isMobileDevice()) return focusDraftEditor(editor, revealSelection) }, [locked, sessionId, editor]) @@ -275,6 +273,11 @@ export const InputBar = memo(function InputBar({ return installDraftKeymap(editor, keyboard, gate) }, [editor, keyboard]) + useEffect(() => { + if (editor === null) return + return installDraftKeyboardDismissal(editor, cardRef) + }, [editor]) + // Button presses steal focus from the editor; suppress at mousedown so // typing continues seamlessly. Lexical's focus() carries preventScroll and // restores the previous selection, so no reveal is needed: the caret has @@ -290,7 +293,7 @@ export const InputBar = memo(function InputBar({ if (target.closest('[data-composer-overlay]') !== null) return if (scrollRef.current?.contains(target)) return const control = target.closest('button, a, input, select, textarea, [role="button"], [contenteditable], [tabindex]') - if (control !== null && e.currentTarget.contains(control)) return + if (control !== null && control !== e.currentTarget && e.currentTarget.contains(control)) return e.preventDefault() // Lexical's stored selection can lag behind native caret moves, so calling // editor.focus() on a focused editor can restore an older caret. @@ -400,6 +403,7 @@ export const InputBar = memo(function InputBar({ click's reopen (close-then-open flickers the chip's open echo). */}
{ 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 e308d326da..c84953db02 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' @@ -1468,6 +1469,81 @@ describe('running and lock semantics', () => { expect(focused).toEqual([true]) }) + it.each([ + { name: 'iPhone', userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_6 like Mac OS X) AppleWebKit/605.1.15 Version/18.6 Mobile/15E148 Safari/604.1', platform: 'iPhone', maxTouchPoints: 5, automatic: false }, + { name: 'Android phone', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Mobile Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 0, automatic: false }, + { name: 'Android tablet', userAgent: 'Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Linux armv8l', maxTouchPoints: 5, automatic: false }, + { name: 'desktop-mode iPad', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 5, automatic: false }, + { name: 'Mac', userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.6 Safari/605.1.15', platform: 'MacIntel', maxTouchPoints: 0, automatic: true }, + { name: 'Windows touchscreen', userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/119.0.0.0 Safari/537.36', platform: 'Win32', maxTouchPoints: 5, automatic: true }, + ])('composer focus and submission respect device classification: $name', async ({ userAgent, platform, maxTouchPoints, automatic }) => { + for (const [name, value] of Object.entries({ userAgent, platform, maxTouchPoints })) { + const descriptor = Object.getOwnPropertyDescriptor(navigator, name) + Object.defineProperty(navigator, name, { configurable: true, value }) + onTestFinished(() => { + if (descriptor === undefined) Reflect.deleteProperty(navigator, name) + else Object.defineProperty(navigator, name, descriptor) + }) + } + vi.resetModules() + const detection = await import('@deepseek-ai/dsh-client-ui-primitives/src/focus.ts') + const device = vi.spyOn(uiPrimitives, 'isMobileDevice').mockImplementation(detection.isMobileDevice) + onTestFinished(() => { device.mockRestore() }) + const outside = document.createElement('input') + document.body.append(outside) + onTestFinished(() => { outside.remove() }) + outside.focus() + + const { view, textarea, props, shell, button, sink } = bench({ draft: 'draft' }) + expect(document.activeElement).toBe(automatic ? textarea : outside) + act(() => { outside.focus() }) + act(() => { view.rerender() }) + 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-file-drag/README.i18n.yaml b/packages/client/ui-file-drag/README.i18n.yaml new file mode 100644 index 0000000000..af7b1ead0d --- /dev/null +++ b/packages/client/ui-file-drag/README.i18n.yaml @@ -0,0 +1,34 @@ +# Bilingual-pair consistency record for README.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 packages/client/ui-file-drag/README.md +/: + en: 354a238a0316cc97 + zh: 8b23d2fb131be7c7 +/deepseek-ai-dsh-client-ui-file-drag: + en: 1cda49c784cdc224 + zh: 12633697bfec7214 +/deepseek-ai-dsh-client-ui-file-drag/summary: + en: b9fd57af1a4d9ed5 + zh: 97e2bfc158465d12 +/deepseek-ai-dsh-client-ui-file-drag/table-of-contents: + en: 122f0d41696980b1 + zh: 5423073f383f055d +/deepseek-ai-dsh-client-ui-file-drag/use-this-package: + en: 86b2a44b7966615b + zh: 5998751060e63a0f +/deepseek-ai-dsh-client-ui-file-drag/understand-the-implementation: + en: d52facbaeb4b2d5e + zh: 674ab93d84588733 +/deepseek-ai-dsh-client-ui-file-drag/model-experience: + en: bcbde555cf1ac05e + zh: c555dba949ddd0e9 +/deepseek-ai-dsh-client-ui-file-drag/model-experience/kv-cache-effect: + en: 410edb0ad2da08cf + zh: 581cbff891b5f34d +/deepseek-ai-dsh-client-ui-file-drag/known-limitations-and-deferred-work: + en: ff752628c43903ac + zh: 1eba3fbd50d35263 +/deepseek-ai-dsh-client-ui-file-drag/known-limitations-and-deferred-work/dev-note: + en: b836f01bd0bceb4a + zh: 25e979141fbe5501 diff --git a/packages/client/ui-file-drag/README.md b/packages/client/ui-file-drag/README.md new file mode 100644 index 0000000000..ce8f34cfca --- /dev/null +++ b/packages/client/ui-file-drag/README.md @@ -0,0 +1,67 @@ +--- +description: "Application-scoped file transfers and localized drag failure feedback." +kind: package-reference +--- + +# @deepseek-ai/dsh-client-ui-file-drag + +English | [中文](README.zh.md) + +## Summary + +Users can drag original images from conversations or the sidebar into the composer, or into other applications through Desktop. Drag failures remain visible after the originating image or panel closes. Transfers between browser windows remain subject to browser capabilities. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + + +## Use this package + +The Web profile includes this plugin alongside locale, the slot renderer, and a shell declaring `shell.overlay`. Consumers inject `fileDrag` and pass `ctx.fileDrag.actions` through their entry’s inject face. `prepare(source, signal)` accepts Blob or URL content with an export name and optional Session resource address, returning synchronous start and release operations. `reportFailure()` uses the plugin dictionary; failure feedback and plugin metadata are available in Chinese, English, and Japanese. Composer drop targets receive `ctx.fileDrag.read`; components do not read a service or React context. + +The service retains one original `File` for a browser drag. The random `application/x-dsh-file` token resolves only in its application instance. A native listener stays on the source node after React unmount, and drag end, drop completion, or plugin disposal clears the transfer. Internal tokens are accepted only over the composer. External files keep the existing full-page drop overlay. Desktop native drags return as ordinary files and use that overlay, including within the source window. + +The desktop bridge is resolved once at activation for protocol version 1. File naming is shared with Desktop through [dsh-util-file-drag](../../util/file-drag/README.md): explicit output suffix wins, compatible suffixes survive, known conflicts use the declared MIME suffix, and unknown types retain the caller name. The plugin owns failure copy and its overlay, which survives source-panel closure. + +Managed browser transfers carry an application-local token and `DownloadURL` pointing to a plugin-owned Blob URL containing the prepared bytes. They do not expose the source workspace URL, filename, or alt text through `text/plain`. A drag started before preparation completes is cancelled quietly; a settled failure reports the localized retry message on the next attempt. Resource-backed images prepare on primary pointer down; other sources may prepare on hover. Releasing inside the source preserves preparation for a subsequent drag. Pointer leave without a pressed drag, release outside the source, blur, or unmount retires unused preparation. Cancellation reaches pending native requests, including Host reads and icon waits. Optional resource borrowing failures fall back to the supplied Blob or the authorized URL read; cancellation never starts that fallback. + + +## Understand the implementation + +
+Implementation internals + +The Cordis plugin owns file preparation, transport choice, retained browser bytes, and transient feedback. [DraggableImage](../ui-primitives/README.md) handles image gestures; [Desktop](../../../apps/desktop/README.md) authorizes borrowed resources and owns temporary native exports. Images are the first consumer; the same callbacks accept PDF and other file content without an image MIME gate. + +
+ + +## Model Experience + +None, as this plugin changes browser interaction without adding model-visible messages or tools. + +#### KV Cache effect + +None; this plugin neither constructs nor sends model requests. + +## Known Limitations and Deferred Work + + + +- Browser tokens do not transfer between applications, windows, or tabs. External browser file delivery depends on Chromium `DownloadURL` and the receiving application; Desktop supplies native file delivery. Unreadable cross-origin images retain browser-native dragging. +- The Blob `DownloadURL` path has no verified external-application delivery result. Receivers that read after the transfer ends may find its URL revoked; use Desktop native dragging when original-file delivery is required. + + +### Dev Note + +
+Working context for maintainers + +None. + +
diff --git a/packages/client/ui-file-drag/README.zh.md b/packages/client/ui-file-drag/README.zh.md new file mode 100644 index 0000000000..4960ef286c --- /dev/null +++ b/packages/client/ui-file-drag/README.zh.md @@ -0,0 +1,67 @@ +--- +description: "应用作用域内的文件传递与本地化拖放失败反馈。" +kind: package-reference +--- + +# @deepseek-ai/dsh-client-ui-file-drag + +[English](README.md) | 中文 + +## 概述 + +用户可以把会话或侧栏中的原图拖回输入框,也可以通过桌面端拖到其他应用。图片或面板关闭后,拖放失败提示仍然可见。浏览器跨窗口传递受浏览器能力限制。 + +## 目录 + +- [使用此包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [模型体验](#model-experience) +- [已知限制与后续工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + + +## 使用此包 + +Web profile 将该插件与 locale、插槽渲染器及声明 `shell.overlay` 的外壳一同加载。消费方注入 `fileDrag`,通过入口的注入接口传递 `ctx.fileDrag.actions`。`prepare(source, signal)` 接受 Blob 或 URL 内容、导出名及可选 Session 资源地址,返回同步启动和释放操作。`reportFailure()` 使用插件词典;失败提示和插件元数据提供中文、英文与日语。输入框接收方获取 `ctx.fileDrag.read`;组件不读取服务或 React context。 + +服务为一次浏览器拖放保留一个原始 `File`。随机的 `application/x-dsh-file` 令牌只能在所属应用实例解析。原生监听器在 React 卸载后仍绑定源节点,拖放结束、投放完成或插件卸载会清除传递。内部令牌仅在输入框范围接受;外部文件保留已有的全页投放遮罩。Desktop 原生拖放返回时属于普通文件,包括拖回源窗口,也使用该遮罩。 + +桌面桥接在插件激活时解析一次,支持协议版本 1。文件命名通过 [dsh-util-file-drag](../../util/file-drag/README.zh.md) 与桌面端共享:显式后缀优先,兼容后缀保留,已知冲突使用声明 MIME 的后缀,未知类型保留调用方名称。失败文案与覆盖层由插件持有,在源面板关闭后仍然可见。 + +托管浏览器传输携带应用内令牌,以及指向插件持有、包含准备字节的 Blob URL 的 `DownloadURL`,不会通过 `text/plain` 暴露源工作区 URL、文件名或替代文本。准备完成前起拖会静默取消;已确定的失败在下一次尝试时显示本地化重试提示。带资源引用的图片仅在主指针按下时准备,其他来源可在悬停时准备。在源图片内松开会保留准备,供后续起拖使用。未按住拖动时移出图片、在图片外松开、窗口失焦或卸载会释放未使用的准备。取消会传递到原生请求,包括 Host 读取和图标等待。可选资源借用失败后回退到传入的 Blob 或经过授权的 URL 读取;取消不会触发回退。 + + +## 理解实现 + +
+实现内部细节 + +Cordis 插件负责文件准备、传输选择、浏览器保留字节与临时反馈。[DraggableImage](../ui-primitives/README.zh.md) 处理图片手势,[Desktop](../../../apps/desktop/README.zh.md) 授权借用资源并持有原生临时导出。图片是首个调用方,同一组回调可接收 PDF 等文件内容,不设图片 MIME 门禁。 + +
+ + +## 模型体验 + +无;该插件只改变浏览器交互,不增加模型可见消息或工具。 + +#### KV Cache effect + +无;该插件既不构建也不发送模型请求。 + +## 已知限制与后续工作 + + + +- 浏览器令牌不能跨应用、窗口或标签页传递。浏览器向外部交付文件依赖 Chromium 的 `DownloadURL` 和接收应用;Desktop 提供原生文件交付。不可读取的跨域图片保留浏览器原生拖动。 +- Blob `DownloadURL` 路径尚无经过验证的外部应用交付结果。传递结束后才读取的接收方可能遇到 URL 已撤销;需要原始文件交付时使用 Desktop 原生拖放。 + + +### 开发备注 + +
+维护者工作上下文 + +无。 + +
diff --git a/packages/client/ui-file-drag/locale/en.json b/packages/client/ui-file-drag/locale/en.json new file mode 100644 index 0000000000..36a9a27247 --- /dev/null +++ b/packages/client/ui-file-drag/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "File dragging", + "description": "Drag original files into the composer or other applications, with localized failure feedback." + } +} diff --git a/packages/client/ui-file-drag/locale/ja.json b/packages/client/ui-file-drag/locale/ja.json new file mode 100644 index 0000000000..3bfb13a66b --- /dev/null +++ b/packages/client/ui-file-drag/locale/ja.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "ファイルのドラッグ&ドロップ", + "description": "元のファイルを入力欄や他のアプリにドラッグし、失敗時には選択した言語で通知します。" + } +} diff --git a/packages/client/ui-file-drag/locale/zh.json b/packages/client/ui-file-drag/locale/zh.json new file mode 100644 index 0000000000..0930345d15 --- /dev/null +++ b/packages/client/ui-file-drag/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "文件拖放", + "description": "将原始文件拖入输入框或其他应用,并提供本地化失败提示。" + } +} diff --git a/packages/client/ui-file-drag/package.json b/packages/client/ui-file-drag/package.json new file mode 100644 index 0000000000..16e865db2a --- /dev/null +++ b/packages/client/ui-file-drag/package.json @@ -0,0 +1,64 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-file-drag", + "description": "Application-owned file drag transfers and localized failure feedback", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-file-drag" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-ui-layout" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "react": "^18.2.0", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-util-file-drag": "workspace:*", + "@deepseek-ai/dsh-util-crypto": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-file-drag/src/client/feedback.tsx b/packages/client/ui-file-drag/src/client/feedback.tsx new file mode 100644 index 0000000000..a90201e195 --- /dev/null +++ b/packages/client/ui-file-drag/src/client/feedback.tsx @@ -0,0 +1,26 @@ +/** File-drag failure presentation in the existing application overlay. */ +import { Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' + +/** Localized transient failure, with identity for restarting its presentation lifetime. */ +export interface FileDragFailure { + readonly text: string + readonly sequence: number +} + +/** Registration-owned feedback source and dismissal callback. */ +export interface FileDragFeedbackInjected { + readonly hooks: { readonly failure: ObservableSnapshot } + readonly dismiss: () => void +} + +/** + * Keep transient failures visible after their originating source unmounts. + * @param props - framework-bound failure source and dismissal callback. + * @returns the current failure toast, or nothing. + */ +export function FileDragFeedback({ useFailure, dismiss }: InjectFace) { + const failure = useFailure(value => value) + return failure === null ? null : +} diff --git a/packages/client/ui-file-drag/src/client/index.ts b/packages/client/ui-file-drag/src/client/index.ts new file mode 100644 index 0000000000..efe3fb7eab --- /dev/null +++ b/packages/client/ui-file-drag/src/client/index.ts @@ -0,0 +1,50 @@ +/** Cordis-owned file transfers and localized feedback shared across independent entries. */ +import type { Context } from '@deepseek-ai/cordis' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { DesktopFileDrag } from '@deepseek-ai/dsh-util-file-drag' +import type { FileDragActions } from '@deepseek-ai/dsh-client-ui-primitives' +import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-layout/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { FileDragFeedback, type FileDragFailure, type FileDragFeedbackInjected } from './feedback.tsx' +import { FileTransfers } from './transfers.ts' +import { en, ja, zh } from './locales.ts' + +/** Application-local file preparation and drop resolution. */ +export interface FileDragService { + readonly actions: FileDragActions + /** @param transfer - browser drop payload. @returns original file for a matching application-local token. */ + readonly read: (transfer: DataTransfer) => File | undefined +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** File transfer ownership scoped to this plugin's lifetime. */ + fileDrag: FileDragService + } +} + +/** Registry and locale used by the independent feedback overlay. */ +export const inject = ['slots', 'locale'] + +/** @param ctx - client plugin context carrying slots and locale. */ +export function apply(ctx: Context): void { + const bridge = (globalThis as typeof globalThis & { dshDesktop?: { protocolVersion: number; fileDrag?: DesktopFileDrag } }).dshDesktop + const desktop = bridge?.protocolVersion === 1 ? bridge.fileDrag : undefined + const failure = createSnapshotStore(null) + const t = ctx.locale.bind('fileDrag') + ctx.effect(() => ctx.locale.register('fileDrag', { zh, en, ja })) + let sequence = 0 + const reportFailure = (): void => { failure.set({ text: t('failed'), sequence: ++sequence }) } + const transfers = new FileTransfers(desktop, reportFailure) + const dismiss = (): void => { failure.set(null) } + ctx.provide('fileDrag', { + actions: { prepare: (source, signal) => transfers.prepare(source, signal), reportFailure }, + read: transfer => transfers.read(transfer), + }) + ctx.effect(() => () => { transfers.dispose(); dismiss() }) + ctx.slots.inject('shell.overlay', () => ctx.slots.register({ + name: 'shell.overlay', id: 'file-drag.feedback', + inject: (): FileDragFeedbackInjected => ({ hooks: { failure }, dismiss }), + }, FileDragFeedback)) +} diff --git a/packages/client/ui-file-drag/src/client/locales.ts b/packages/client/ui-file-drag/src/client/locales.ts new file mode 100644 index 0000000000..9497b29dbc --- /dev/null +++ b/packages/client/ui-file-drag/src/client/locales.ts @@ -0,0 +1,15 @@ +/** File drag failure copy owned by the transfer plugin. */ +export const zh = { failed: '拖放失败,请重试' } + +/** English file drag failure copy. */ +export const en = { failed: 'Drag failed. Try again.' } satisfies Record + +/** Japanese file drag failure copy. */ +export const ja = { failed: 'ドラッグ&ドロップに失敗しました。もう一度お試しください。' } satisfies Record + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** File transfer failure feedback. */ + fileDrag: keyof typeof zh + } +} diff --git a/packages/client/ui-file-drag/src/client/transfers.ts b/packages/client/ui-file-drag/src/client/transfers.ts new file mode 100644 index 0000000000..88c1419588 --- /dev/null +++ b/packages/client/ui-file-drag/src/client/transfers.ts @@ -0,0 +1,164 @@ +/** File transport selected once per plugin activation; sources only receive prepared operations. */ +import { + FILE_DRAG_TOKEN_TYPE, resolveDragFileName, + type DesktopFileDrag, type DesktopFileSource, type FileDragHandle, type FileDragRequestId, +} from '@deepseek-ai/dsh-util-file-drag' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { FileDragSource, PreparedFileDrag } from '@deepseek-ai/dsh-client-ui-primitives' + +/** Application-local browser transfer owner, including detached source-node drag end. */ +export class FileTransfers { + private active: { token: string; file: File; end: () => void } | undefined + private readonly prepared = new Set<() => void>() + private disposed = false + private readonly lifetime = new AbortController() + + /** @param desktop - verified native bridge, absent in Web. @param reportFailure - localized plugin feedback. */ + constructor(private readonly desktop: DesktopFileDrag | undefined, private readonly reportFailure: () => void) {} + + /** + * Resolve this instance's active transfer. + * @param transfer - browser drop data. + * @returns retained bytes only for this instance's current token. + */ + read(transfer: DataTransfer): File | undefined { + return this.active?.token === transfer.getData(FILE_DRAG_TOKEN_TYPE) ? this.active.file : undefined + } + + private assertOpen(signal: AbortSignal): void { + signal.throwIfAborted() + } + + private async prepareNative( + desktop: DesktopFileDrag, source: DesktopFileSource, signal: AbortSignal, + ): Promise { + this.assertOpen(signal) + const requestId = randomUUID() as FileDragRequestId + const cancel = (): void => { desktop.cancel(requestId) } + signal.addEventListener('abort', cancel, { once: true }) + try { return await desktop.prepare(source, requestId) } + finally { signal.removeEventListener('abort', cancel) } + } + + /** + * Prepare bytes or borrow an authorized native file. + * @param source - content plus export naming. + * @param signal - caller cancellation. + * @returns synchronous drag startup and preparation release. + */ + async prepare(source: FileDragSource, signal: AbortSignal): Promise { + signal = AbortSignal.any([signal, this.lifetime.signal]) + this.assertOpen(signal) + const desktop = this.desktop + if (desktop === undefined) { + const file = await this.readSource(source, signal) + this.assertOpen(signal) + return this.browser(file) + } + let handle: FileDragHandle | undefined + try { + if (source.resourceAddress !== undefined) { + const bytes = source.kind === 'blob' ? await source.blob.arrayBuffer() : undefined + const hash = bytes === undefined ? undefined + : Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes)), byte => byte.toString(16).padStart(2, '0')).join('') + this.assertOpen(signal) + try { handle = await this.prepareNative(desktop, { + kind: 'resource', address: source.resourceAddress, name: source.name, + mediaType: source.mediaType, outputExtension: source.outputExtension, + ...(hash === undefined ? {} : { sha256: hash }), + ...(source.kind === 'blob' ? { size: source.blob.size } : {}), + }, signal) } catch (_error) { + // Borrowing is optional; the Blob or authorized URL remains the source for a copy. + this.assertOpen(signal) + } + } + if (handle === undefined) { + const file = await this.readSource(source, signal) + handle = await this.prepareNative(desktop, { kind: 'bytes', bytes: new Uint8Array(await file.arrayBuffer()), + name: file.name, mediaType: file.type, outputExtension: source.outputExtension }, signal) + if (handle === undefined) throw new Error('File preparation unavailable') + } + this.assertOpen(signal) + return this.native(desktop, handle) + } catch (error) { + if (handle !== undefined) desktop.release(handle) + throw error + } + } + + private async readSource(source: FileDragSource, signal: AbortSignal): Promise { + this.assertOpen(signal) + let content: Blob + if (source.kind === 'url') { + const response = await fetch(source.url, { signal, referrerPolicy: 'no-referrer' }) + if (!response.ok) throw new Error('File read failed') + content = await response.blob() + } else content = source.blob + this.assertOpen(signal) + const mediaType = source.mediaType || content.type + return new File([content], resolveDragFileName({ ...source, mediaType }), { type: mediaType }) + } + + private native(desktop: DesktopFileDrag, nativeHandle: FileDragHandle): PreparedFileDrag { + let starting: Promise | undefined + const release = (): void => { + if (!this.prepared.delete(release)) return + if (starting === undefined) desktop.release(nativeHandle) + else void starting.then(() => { desktop.release(nativeHandle) }) + } + this.prepared.add(release) + return { + start: () => { + if (!this.prepared.has(release)) return false + starting = desktop.start(nativeHandle).then((started) => { if (!started) this.reportFailure() }, this.reportFailure) + return false + }, + release, + } + } + + private browser(file: File): PreparedFileDrag { + const url = URL.createObjectURL(file) + let released = false + let started = false + const releaseUrl = (): void => { this.prepared.delete(release); URL.revokeObjectURL(url) } + const release = (): void => { released = true; if (!started) releaseUrl() } + this.prepared.add(release) + return { + start: (transfer, source) => { + if (released || this.disposed) return false + this.active?.end() + started = true + const token = randomUUID() + let ended = false + const end = (): void => { + if (ended) return + ended = true + source.removeEventListener('dragend', end) + window.removeEventListener('drop', dropped) + this.active = undefined + started = false + if (released) releaseUrl() + } + const dropped = (): void => { queueMicrotask(end) } + this.active = { token, file, end } + source.addEventListener('dragend', end, { once: true }) + window.addEventListener('drop', dropped, { once: true }) + transfer.items.clear() + transfer.effectAllowed = 'copy' + transfer.setData(FILE_DRAG_TOKEN_TYPE, token) + transfer.setData('DownloadURL', `${file.type || 'application/octet-stream'}:${file.name}:${url}`) + return true + }, + release, + } + } + + /** End active transfers and retire every preparation, including sources detached during dragging. */ + dispose(): void { + this.disposed = true + this.lifetime.abort(new Error('File transfers disposed')) + this.active?.end() + for (const release of this.prepared) release() + } +} diff --git a/packages/client/ui-file-drag/src/index.ts b/packages/client/ui-file-drag/src/index.ts new file mode 100644 index 0000000000..01bb3cdbe2 --- /dev/null +++ b/packages/client/ui-file-drag/src/index.ts @@ -0,0 +1,4 @@ +/** Host Loader entry; file dragging executes in the browser and desktop shell. */ + +/** Register the browser package without adding Host behavior. */ +export function apply(): void {} diff --git a/packages/client/ui-file-drag/tests/plugin.client.spec.tsx b/packages/client/ui-file-drag/tests/plugin.client.spec.tsx new file mode 100644 index 0000000000..b0c83a62ec --- /dev/null +++ b/packages/client/ui-file-drag/tests/plugin.client.spec.tsx @@ -0,0 +1,102 @@ +// @vitest-environment jsdom +/** Plugin lifecycle and feedback outlive the source component. */ +import { File as NodeFile } from 'node:buffer' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, expect, it, onTestFinished, vi } from 'vitest' +import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { DraggableImage } from '@deepseek-ai/dsh-client-ui-primitives' +import { FILE_DRAG_TOKEN_TYPE, type DesktopFileDrag, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' +import * as plugin from '../src/client/index.ts' +import { apply as applyHost } from '../src/index.ts' + +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers() }) +const revokeUrl = vi.fn<(url: string) => void>() + +function transfer(token = ''): DataTransfer { + const data = new Map([[FILE_DRAG_TOKEN_TYPE, token]]) + return Object.assign({} as DataTransfer, { + items: { clear: vi.fn() }, effectAllowed: 'none', + getData: (type: string) => data.get(type) ?? '', + setData: (type: string, value: string) => { data.set(type, value) }, + }) +} + +async function bench(language: 'zh' | 'en' | 'ja' = 'zh') { + revokeUrl.mockReset() + vi.stubGlobal('File', NodeFile) + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:transfer'), revokeObjectURL: revokeUrl })) + const runtime = await SlotTestRuntime.create() + onTestFinished(() => runtime.dispose()) + const locale = new LocaleRuntime(runtime.ctx) + onTestFinished(locale.addLanguage({ id: 'ja', label: '日本語', fallback: 'en' })) + locale.setLocale(language) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + const owner = await runtime.mount(plugin) + await runtime.root.declare({ 'shell.overlay': { kind: 'list', scope: 'root' } }, + ({ renderSlot }) => renderSlot('shell.overlay', {})) + const view = runtime.renderRoot() + return { runtime, owner, view, service: runtime.ctx.fileDrag } +} + +it('keeps the Host entry empty', () => { applyHost() }) + +it('retains bytes after source unmount and releases on the detached node dragend', async () => { + const { service, owner, runtime } = await bench() + const file = new File(['pdf-original'], '报告.pdf', { type: 'application/pdf' }) + const source = render() + const node = source.getByRole('img') + const data = transfer() + expect(service.read(data)).toBeUndefined() + await act(async () => { fireEvent.pointerEnter(node) }) + fireEvent.dragStart(node, { dataTransfer: data }) + source.unmount() + const received = service.read(data)! + expect(received.name).toBe(file.name) + expect(await received.text()).toBe('pdf-original') + expect(data.getData('DownloadURL')).toBe('application/pdf:报告.pdf:blob:transfer') + expect(service.read(transfer('foreign'))).toBeUndefined() + node.dispatchEvent(new Event('dragend')) + expect(service.read(data)).toBeUndefined() + expect(revokeUrl).toHaveBeenCalledWith('blob:transfer') + await owner.dispose() + expect(runtime.ctx.get('fileDrag')).toBeUndefined() + expect(runtime.slots.entries('shell.overlay')).toHaveLength(0) +}) + +it.each([ + ['zh', '拖放失败,请重试'], + ['en', 'Drag failed. Try again.'], + ['ja', 'ドラッグ&ドロップに失敗しました。もう一度お試しください。'], +] as const)('reports a late native failure after source unmount in %s', async (language, message) => { + const pending = Promise.withResolvers() + const handle = 'handle' as FileDragHandle + const desktop: DesktopFileDrag = { + prepare: vi.fn(async () => handle), cancel: vi.fn(), start: vi.fn(() => pending.promise), release: vi.fn(), + } + vi.stubGlobal('dshDesktop', { protocolVersion: 1, fileDrag: desktop }) + const { service } = await bench(language) + vi.stubGlobal('dshDesktop', { protocolVersion: 2 }) + const file = new File(['image'], 'image.png', { type: 'image/png' }) + const source = render() + await act(async () => { fireEvent.pointerEnter(source.getByRole('img')) }) + fireEvent.dragStart(source.getByRole('img'), { dataTransfer: transfer() }) + source.unmount() + expect(desktop.release).not.toHaveBeenCalled() + vi.useFakeTimers() + await act(async () => { pending.resolve(false); await pending.promise }) + expect(desktop.release).toHaveBeenCalledWith(handle) + expect(screen.getByRole('alert').textContent).toBe(message) + await act(async () => { await vi.runAllTimersAsync() }) + expect(screen.queryByRole('alert')).toBeNull() +}) + +it('ignores an unsupported desktop protocol and removes feedback with the plugin', async () => { + vi.stubGlobal('dshDesktop', { protocolVersion: 2, fileDrag: {} }) + const { service, owner } = await bench() + act(() => { service.actions.reportFailure() }) + expect(screen.getByRole('alert')).toBeTruthy() + await owner.dispose() + expect(screen.queryByRole('alert')).toBeNull() +}) diff --git a/packages/client/ui-file-drag/tests/transfers.client.spec.ts b/packages/client/ui-file-drag/tests/transfers.client.spec.ts new file mode 100644 index 0000000000..1b0a3d3fe1 --- /dev/null +++ b/packages/client/ui-file-drag/tests/transfers.client.spec.ts @@ -0,0 +1,202 @@ +// @vitest-environment jsdom +import { File as NodeFile, Blob as NodeBlob } from 'node:buffer' +import { webcrypto } from 'node:crypto' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { FILE_DRAG_TOKEN_TYPE, type DesktopFileDrag, type FileDragHandle } from '@deepseek-ai/dsh-util-file-drag' +import { FileTransfers } from '../src/client/transfers.ts' + +const revokeUrl = vi.fn<(url: string) => void>() +beforeEach(() => { + revokeUrl.mockReset() + vi.stubGlobal('File', NodeFile) + vi.stubGlobal('Blob', NodeBlob) + vi.stubGlobal('crypto', webcrypto) + vi.stubGlobal('URL', Object.assign(URL, { createObjectURL: vi.fn(() => 'blob:copy'), revokeObjectURL: revokeUrl })) +}) +afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks() }) +const blobSource = () => ({ kind: 'blob' as const, blob: new Blob(['%PDF-original'], { type: 'application/pdf' }), name: 'report', mediaType: '' }) +const signal = () => new AbortController().signal +const native = (): DesktopFileDrag => ({ + prepare: vi.fn(async () => 'handle' as FileDragHandle), cancel: vi.fn(), start: vi.fn(async () => true), release: vi.fn(), +}) +function transfer(): DataTransfer { + const data = new Map() + return Object.assign({} as DataTransfer, { items: { clear: vi.fn() }, effectAllowed: 'none', + setData: (key: string, value: string) => { data.set(key, value) }, getData: (key: string) => data.get(key) ?? '' }) +} + +it('borrows an authorized resource before fetching URL bytes', async () => { + const desktop = native() + const fetcher = vi.fn() + vi.stubGlobal('fetch', fetcher) + const files = new FileTransfers(desktop, vi.fn()) + const prepared = await files.prepare({ kind: 'url', url: '/api/file', name: 'report.pdf', mediaType: 'application/pdf', resourceAddress: 'dsh-resource://file/session/s/report.pdf' }, signal()) + expect(desktop.prepare).toHaveBeenCalledWith(expect.objectContaining({ kind: 'resource', address: 'dsh-resource://file/session/s/report.pdf' }), expect.any(String)) + expect(fetcher).not.toHaveBeenCalled() + expect(prepared.start(transfer(), document.createElement('div'))).toBe(false) + await Promise.resolve() + prepared.release() + await Promise.resolve() + expect(desktop.release).toHaveBeenCalledOnce() + prepared.release() + expect(prepared.start(transfer(), document.createElement('div'))).toBe(false) + files.dispose() +}) + +it('compares original Blob bytes by digest and copies a mismatching resource', async () => { + const desktop = native() + vi.mocked(desktop.prepare).mockResolvedValueOnce(undefined) + const files = new FileTransfers(desktop, vi.fn()) + const prepared = await files.prepare({ ...blobSource(), resourceAddress: 'dsh-resource://file/session/s/report.pdf', outputExtension: '.bin' }, signal()) + expect(desktop.prepare).toHaveBeenNthCalledWith(1, expect.objectContaining({ kind: 'resource', size: 13 }), expect.any(String)) + const request = vi.mocked(desktop.prepare).mock.calls[0]![0] + expect(request.kind === 'resource' ? request.sha256 : undefined).toMatch(/^[a-f0-9]{64}$/u) + expect(desktop.prepare).toHaveBeenNthCalledWith(2, expect.objectContaining({ kind: 'bytes', name: 'report.bin', outputExtension: '.bin' }), expect.any(String)) + prepared.release() + files.dispose() +}) + +it('fetches generic remote bytes and preserves explicit unknown types and extensions', async () => { + const fetcher = vi.fn(async () => new Response('sheet-bytes', { headers: { 'Content-Type': 'application/x-custom' } })) + vi.stubGlobal('fetch', fetcher) + const files = new FileTransfers(undefined, vi.fn()) + const prepared = await files.prepare({ kind: 'url', url: '/sheet', name: 'sheet.custom', mediaType: '' }, signal()) + const data = transfer() + const node = document.createElement('div') + expect(prepared.start(data, node)).toBe(true) + const file = files.read(data)! + expect(file.name).toBe('sheet.custom') + expect(await file.text()).toBe('sheet-bytes') + expect(data.getData(FILE_DRAG_TOKEN_TYPE)).not.toBe('') + node.dispatchEvent(new Event('dragend')) + expect(files.read(data)).toBeUndefined() + prepared.release() + files.dispose() +}) + +it('keeps retained files through the drop task and retires them afterward', async () => { + const files = new FileTransfers(undefined, vi.fn()) + const prepared = await files.prepare(blobSource(), signal()) + const data = transfer() + prepared.start(data, document.createElement('div')) + prepared.release() + window.dispatchEvent(new Event('drop')) + expect(files.read(data)).toBeDefined() + await Promise.resolve() + expect(files.read(data)).toBeUndefined() + expect(revokeUrl).toHaveBeenCalledOnce() + expect(prepared.start(data, document.createElement('div'))).toBe(false) + files.dispose() +}) + +it('replaces active transfers and disposes mounted preparations', async () => { + const files = new FileTransfers(undefined, vi.fn()) + const first = await files.prepare(blobSource(), signal()) + const second = await files.prepare(blobSource(), signal()) + const a = transfer(), b = transfer() + first.start(a, document.createElement('div')) + second.start(b, document.createElement('div')) + expect(files.read(a)).toBeUndefined() + expect(files.read(b)).toBeDefined() + files.dispose() + expect(files.read(b)).toBeUndefined() + expect(revokeUrl).toHaveBeenCalledTimes(2) + await expect(files.prepare(blobSource(), signal())).rejects.toThrow('File transfers disposed') +}) + +it('reports native start rejection and releases failed preparation', async () => { + const desktop = native() + const failed = vi.fn() + const files = new FileTransfers(desktop, failed) + const prepared = await files.prepare(blobSource(), signal()) + vi.mocked(desktop.start).mockRejectedValueOnce(new Error('OS failed')) + prepared.start(transfer(), document.createElement('div')) + await Promise.resolve() + expect(failed).toHaveBeenCalledOnce() + files.dispose() + vi.mocked(desktop.prepare).mockResolvedValue(undefined) + const retry = new FileTransfers(desktop, failed) + await expect(retry.prepare(blobSource(), signal())).rejects.toThrow('File preparation unavailable') + retry.dispose() +}) + +it('rejects unreadable remote files without retaining a preparation', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('', { status: 404 }))) + const files = new FileTransfers(undefined, vi.fn()) + await expect(files.prepare({ kind: 'url', url: '/missing', mediaType: '' }, signal())).rejects.toThrow('File read failed') + files.dispose() +}) + +it('copies already loaded PDF bytes when optional resource authorization rejects', async () => { + const desktop = native() + vi.mocked(desktop.prepare).mockRejectedValueOnce(new Error('Host resource unavailable')) + const files = new FileTransfers(desktop, vi.fn()) + const prepared = await files.prepare({ ...blobSource(), resourceAddress: 'dsh-resource://file/session/s/report.pdf' }, signal()) + const request = vi.mocked(desktop.prepare).mock.calls[1]![0] + expect(request.kind).toBe('bytes') + expect(request.name).toBe('report.pdf') + if (request.kind === 'bytes') expect(new TextDecoder().decode(request.bytes)).toBe('%PDF-original') + prepared.release() + files.dispose() +}) + +it('sends cancellation before native preparation has returned its handle', async () => { + const desktop = native() + const pending = Promise.withResolvers() + vi.mocked(desktop.prepare).mockReturnValue(pending.promise) + const files = new FileTransfers(desktop, vi.fn()) + const controller = new AbortController() + const preparing = files.prepare(blobSource(), controller.signal) + const rejected = expect(preparing).rejects.toThrow() + await vi.waitFor(() => { expect(desktop.prepare).toHaveBeenCalledOnce() }) + const requestId = vi.mocked(desktop.prepare).mock.calls[0]![1] + controller.abort() + expect(desktop.cancel).toHaveBeenCalledWith(requestId) + pending.resolve('late' as FileDragHandle) + await rejected + expect(desktop.release).toHaveBeenCalledWith('late') + files.dispose() +}) + +it('does not let an older drop microtask clear a newer transfer and supplies a MIME for untyped files', async () => { + const files = new FileTransfers(undefined, vi.fn()) + const first = await files.prepare(blobSource(), signal()) + const second = await files.prepare({ kind: 'blob', blob: new Blob(['raw']), name: 'raw.custom', mediaType: '' }, signal()) + const a = transfer(), b = transfer() + first.start(a, document.createElement('div')) + first.release() + window.dispatchEvent(new Event('drop')) + second.start(b, document.createElement('div')) + await Promise.resolve() + expect(files.read(b)?.name).toBe('raw.custom') + expect(b.getData('DownloadURL')).toBe('application/octet-stream:raw.custom:blob:copy') + files.dispose() +}) + +it.each(['abort', 'dispose'] as const)('releases a native preparation completed after %s', async (action) => { + const desktop = native() + const pending = Promise.withResolvers() + vi.mocked(desktop.prepare).mockReturnValue(pending.promise) + const files = new FileTransfers(desktop, vi.fn()) + const abort = new AbortController() + const preparing = files.prepare(blobSource(), abort.signal) + const rejected = expect(preparing).rejects.toThrow() + await vi.waitFor(() => { expect(desktop.prepare).toHaveBeenCalledOnce() }) + if (action === 'abort') abort.abort() + else files.dispose() + pending.resolve('late' as FileDragHandle) + await rejected + expect(desktop.release).toHaveBeenCalledWith('late') + files.dispose() +}) + +it('rejects a Web preparation when the plugin is disposed while fetching', async () => { + const pending = Promise.withResolvers() + vi.stubGlobal('fetch', vi.fn(() => pending.promise)) + const files = new FileTransfers(undefined, vi.fn()) + const preparing = files.prepare({ kind: 'url', url: '/pending', mediaType: 'application/pdf' }, signal()) + const rejected = expect(preparing).rejects.toThrow() + files.dispose() + pending.resolve(new Response('pdf')) + await rejected +}) diff --git a/packages/client/ui-file-drag/tsconfig.json b/packages/client/ui-file-drag/tsconfig.json new file mode 100644 index 0000000000..7aec375e70 --- /dev/null +++ b/packages/client/ui-file-drag/tsconfig.json @@ -0,0 +1,39 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../store" + }, + { + "path": "../ui-slots" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-renderer" + }, + { + "path": "../ui-layout" + }, + { + "path": "../../util/file-drag" + }, + { + "path": "../../util/crypto" + }, + { + "path": "../locale/tsconfig.client.json" + } + ] +} diff --git a/packages/client/ui-file-drag/tsdown.config.ts b/packages/client/ui-file-drag/tsdown.config.ts new file mode 100644 index 0000000000..7aa03cebc1 --- /dev/null +++ b/packages/client/ui-file-drag/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle(import.meta.dirname, ['lib/types/index.js']) 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 09f0933f67..90aeb2e9ad 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 } @@ -1647,64 +1651,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 a0d23a53a8..d9e1d3f209 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 ab2f5b4a66..7795512361 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -15,26 +15,26 @@ 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: 4091bc18d6eabedd - zh: c59f84589a5f692a + 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: 629f57d60fe5cce1 - zh: 5263447a14c21d61 + en: 02f310acbae29955 + zh: 1e41393d5d714b2d /deepseek-ai-dsh-client-ui-primitives/use-this-package/localizing-copy: - en: 4aef65dea11de083 - zh: abc22cd7c65cdf38 + en: a943603b74080ef5 + zh: 43f7a0188aff271b /deepseek-ai-dsh-client-ui-primitives/understand-the-implementation: 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: 039cf54697c97608 - zh: ac95443caa5be0ed + 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 202ba0b9e3..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)). @@ -46,10 +48,11 @@ Check this table before writing a control in a feature package. A plugin cannot | `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. | | `Input` | Single-line text entry for search boxes and inline forms. Its ref targets the native input for focus and is cleared on unmount. | | `InlineEditor` | Autofocused multi-line inline editor for short drafts such as queued messages and goals. It grows with its text and its width up to six lines, then scrolls; Enter saves, Shift+Enter breaks the line, Enter during IME composition does nothing, and Escape cancels. The caller supplies the localized `label` and rebinds the scrollbar on an elevated surface. | +| `ZoomControls`, `DOCUMENT_ZOOM` | Floating document zoom controls with localized labels, fixed presets, and visibility/activity callbacks. An omitted `fit` hides the fitting row. The toolbar carries `data-document-zoom-controls`, plus `data-document-zoom-visible` while visible. An owner `className` overrides `bottom` and `max-width` regardless of stylesheet order; `inset-inline-start` and `transform` center the toolbar and move it on enter and exit, `opacity` fades it, and all three stay with the primitive. Renderers share the published scale limits. | | `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. | @@ -62,6 +65,7 @@ Check this table before writing a control in a feature package. A plugin cannot | `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. | | `Tooltip` | Hover and keyboard-focus text anchored to a cloned child; optional `delayMs` controls hover delay and `focusDelayMs` controls keyboard-focus delay, both defaulting to 0 ms. Optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. | | `HoverCard`, `HoverCardGroup` | Readable, optionally copyable hover preview; a physical list can opt cards into shared first-display dwell and exclusive display. | +| `DraggableImage` | Original-image gestures with explicit transfer and feedback callbacks. | | `ImageLightbox` | Shared image modal with focus restoration and Escape dismissal. | | `Toast` | Transient top-center banner held for the owner's `holdMs`. | | `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank, requests no saved-password autofill, and reports only whether a value is configured. | @@ -92,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. @@ -118,9 +122,11 @@ The nearest `MarkdownDelegateProvider` supplies optional `openExternalLink` and `ImageLightbox` is the shared original-image modal with focus restoration and Escape dismissal. Like `Modal`, its mask leaves the Windows caption unpainted, and its close control sits 20px below the caption. The internal thumbnail renderer receives loading/failure labels from its owner. `HoverCard.inline` keeps a file link in the text flow and uses the shared menu material, keyboard-visible focus, and placement above or below the anchor without covering it. Its transparent hover region connects the anchor to the panel across the visual gap, so crossing the gap does not activate content beneath it. Escape closes an open thumbnail even when focus is elsewhere; subsequent Escape presses reach the owner. `MarkdownDelegateProvider.fileImages` supplies a decoded-path resolver and complete image labels: settled image links gain hover previews, and standalone images gain lightbox activation. Image-only anchors retain their single navigation target. The parser recovers only complete, unescaped, standalone local image references with bare spaces and an unambiguous supported image suffix; code and ambiguous destinations remain literal. +`DraggableImage` receives explicit `fileDrag: FileDragActions`, also supplied through `MarkdownDelegateProvider.fileImages.fileDrag`. The [file-drag plugin](../ui-file-drag/README.md) owns preparation, transfer, and localized failure feedback; the image primitive manages gestures and cancellation. Resource-backed images prepare on primary pointer down; other sources also prepare on hover. Releasing inside the image preserves preparation for the next gesture; pointer leave releases unused preparation. Markdown export names use the decoded path basename. Panning images skip preparation. Starting before preparation finishes cancels that gesture quietly; an actual preparation failure reports a retry message. Managed Web transfers carry an application-local token and a `DownloadURL` pointing to a plugin-owned Blob URL, with no `text/plain` payload. + ### Localizing copy -The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, and `WebBlock` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. +The atoms cannot read the application locale, so every piece of user-facing copy arrives through required label props. `HoverCard`, `TerminalBlock`, `JsonTree`, `CodeBlock`, `MarkdownText`, `JsonBlock`, `ConnectionIndicator`, `Modal`, `DiffBlock`, `ReadBlock`, `SearchBlock`, `WebBlock`, and `ZoomControls` accept complete localized labels. The package owns no language fallback; omission fails typechecking, and each feature maps its typed `t` seat into the primitive's label interface. ----- @@ -153,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 | @@ -208,6 +214,7 @@ None; this package neither assembles nor sends a provider request. These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap. +- **Browser image transfers** — external targets depend on Chromium `DownloadURL` and the receiving application; native file delivery is provided by Desktop. Web tokens do not transfer between windows or tabs. Unreadable cross-origin images retain browser-native dragging, as do linked Markdown images and Markdown without `fileImages`; those native paths may include URLs. - **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations. - **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network. - **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it. @@ -215,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 0241739e2c..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))。 @@ -46,10 +48,11 @@ kind: "package-library" | `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 | | `Input` | 单行文本输入,用于搜索框与行内表单。ref 指向原生输入框,供焦点控制使用,并在卸载时清空。 | | `InlineEditor` | 自动聚焦的多行行内编辑器,用于排队消息、goal 等短草稿。它随文本和宽度增高,最多六行,之后滚动;Enter 保存,Shift+Enter 换行,输入法组合中的 Enter 不生效,Escape 取消。调用方提供本地化 `label`,并在 elevated 表面上重绑滚动条。 | +| `ZoomControls`, `DOCUMENT_ZOOM` | 悬浮文档缩放控件,接收本地化标签、固定预设,以及可见性和交互状态回调。省略 `fit` 时不显示适配项。工具栏带 `data-document-zoom-controls`,可见时另带 `data-document-zoom-visible`。owner 的 `className` 覆盖 `bottom` 与 `max-width`,不受样式表加载顺序影响;`inset-inline-start` 和 `transform` 负责居中与进出场位移,`opacity` 负责淡入淡出,三者都由原语保留。渲染器共用导出的缩放范围。 | | `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"` 以实心勾表示完成、空心圆表示等待。 | @@ -62,6 +65,7 @@ kind: "package-library" | `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 | | `Tooltip` | 锚定在克隆子元素上的悬停与键盘聚焦提示;可选的 `delayMs` 控制悬停延迟,`focusDelayMs` 控制键盘聚焦延迟,均默认为 0 毫秒。可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 | | `HoverCard`、`HoverCardGroup` | 可阅读、可选带复制按钮的悬停预览;物理列表可选择让卡片共享首次显示等待,并互斥显示。 | +| `DraggableImage` | 通过显式传递与反馈回调提供原图拖放。 | | `ImageLightbox` | 共享图片浮层,支持焦点恢复与 Esc 关闭。 | | `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 | | `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,请求浏览器不要自动填入已保存的密码,只报告是否已配置。 | @@ -92,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。 @@ -118,9 +122,11 @@ kind: "package-library" `ImageLightbox` 是共享原图浮层,支持焦点恢复与 Esc 关闭。与 `Modal` 相同,其遮罩不覆盖 Windows 顶栏,关闭按钮位于顶栏下方 20px。包内缩略图渲染器由调用方提供加载及失败文案。`HoverCard.inline` 使文件链接保持在文本流内,并使用共享菜单材质、键盘可见焦点,并在锚点上方或下方定位而不遮挡锚点。即使焦点位于其他位置,Esc 也会关闭已打开的缩略图;后续 Esc 按键继续传给 owner。`MarkdownDelegateProvider.fileImages` 提供已解码路径解析器与完整图片文案:消息落定后的图片链接支持悬停预览,独立图片支持点击放大。仅包含图片的链接保留单一导航目标。解析器仅恢复完整、未转义、独立成段且带明确图片扩展名的含裸空格本地图片引用;代码与有歧义的目标保持原文。 透明悬停区域连接锚点与弹层之间的视觉空隙,鼠标经过时不会激活下方内容。 +`DraggableImage` 接收显式的 `fileDrag: FileDragActions`,受管理的 Markdown 图片通过 `MarkdownDelegateProvider.fileImages.fileDrag` 接收同一组回调。[文件拖放插件](../ui-file-drag/README.zh.md) 负责准备、传递与本地化失败提示;图片原语负责手势和取消。带资源引用的图片仅在主指针按下时准备,其他来源也可在悬停时准备。在图片内松开保留准备供下一次手势使用;指针移开释放未使用的准备,平移模式跳过准备。Markdown 导出名取已解码路径的文件名。在准备完成前起拖会静默取消本次手势;实际准备失败才显示重试提示。受管理的 Web 拖放携带应用内令牌和指向插件所持 Blob URL 的 `DownloadURL`,不设置 `text/plain`。 + ### 本地化文案 -这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock` 与 `WebBlock` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 +这些原子组件无法读取应用 locale,因此每段面向用户的文案都必须通过 label prop 提供。`HoverCard`、`TerminalBlock`、`JsonTree`、`CodeBlock`、`MarkdownText`、`JsonBlock`、`ConnectionIndicator`、`Modal`、`DiffBlock`、`ReadBlock`、`SearchBlock`、`WebBlock` 与 `ZoomControls` 接收完整的本地化 label。本包不拥有语言回退;遗漏会导致类型检查失败,各功能会把带类型的 `t` 席位映射到 primitive 的 label 接口。 ----- @@ -153,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 按实例生成 | @@ -208,6 +214,7 @@ kind: "package-library" 这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。 +- **浏览器图片传递**:外部接收依赖 Chromium 的 `DownloadURL` 与接收应用;原生文件交付由 Desktop 提供。Web 令牌不跨窗口或标签页传递。不可读取的跨源图片保留浏览器原生拖动,链接内的 Markdown 图片及未配置 `fileImages` 的 Markdown 也保留原生行为;这些原生路径可能携带 URL。 - **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。 - **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。 - **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。 @@ -215,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/package.json b/packages/client/ui-primitives/package.json index 09dd9df339..73f1c57c1a 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -48,7 +48,8 @@ "mdast-util-math": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "micromark-util-sanitize-uri": "^2.0.1", - "simple-icons": "16.31.0" + "simple-icons": "16.31.0", + "@deepseek-ai/dsh-util-file-drag": "workspace:*" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-primitives/src/DraggableImage.tsx b/packages/client/ui-primitives/src/DraggableImage.tsx new file mode 100644 index 0000000000..a7ce07efce --- /dev/null +++ b/packages/client/ui-primitives/src/DraggableImage.tsx @@ -0,0 +1,26 @@ +/** Original-image drag gestures owned by a presentation primitive. */ +import { forwardRef, type ImgHTMLAttributes } from 'react' +import { useImageDrag } from './image-drag.ts' +import type { FileDragActions } from './file-drag-actions.ts' + +/** Image presentation plus original content and explicit file preparation callbacks. */ +export interface DraggableImageProps extends Omit, 'src' | 'draggable' | 'onDragStart' | 'onDragEnd' | 'onPointerEnter' | 'onPointerLeave' | 'onPointerDown' | 'onPointerUp' | 'onClickCapture'> { + readonly fileDrag: FileDragActions + readonly src: string + readonly name: string + readonly resourceAddress?: string | undefined + readonly original?: File | undefined +} + +/** + * Render an image with original-file dragging; using explicit application-owned callbacks. + * @param props - image attributes, original filename and file preparation callbacks. + * @param ref - image element for measurement and pointer-mode bindings. + * @returns a draggable image that suppresses only the mouse click following its drag. + */ +export const DraggableImage = forwardRef(function DraggableImage( + { fileDrag, src, name, original, resourceAddress, ...props }, ref, +) { + const drag = useImageDrag(fileDrag, src, name, original, resourceAddress) + return +}) 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
} diff --git a/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts b/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts index 917416b7cf..9d69604623 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts +++ b/packages/client/ui-sidebar-documentpreview/src/client/zoom/types.ts @@ -1,3 +1,5 @@ +import type { DocumentZoomLabels } from '@deepseek-ai/dsh-client-ui-primitives' + /** Automatic fit or an explicit actual-size scale selected by the reader. */ export type ZoomPreference = | { readonly kind: 'fit-width' } @@ -6,24 +8,7 @@ export type ZoomPreference = /** Default preference for a newly opened zoomable preview. */ export const FIT_WIDTH: ZoomPreference = { kind: 'fit-width' } -/** Minimum scale retained by a fixed zoom preference. */ -export const MIN_FIXED_ZOOM = 0.25 - -/** Maximum scale retained by a fixed zoom preference. */ -export const MAX_FIXED_ZOOM = 4 - -/** Scale interval used by the incremental controls. */ -export const ZOOM_STEP = 0.25 - -/** Fixed scale choices shown in the zoom menu. */ -export const ZOOM_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const - /** Localized copy consumed by the framework-free zoom controls. */ -export interface ZoomLabels { - readonly controls: string - readonly menu: string - readonly out: string - readonly into: string +export interface ZoomLabels extends DocumentZoomLabels { readonly fitWidth: string - readonly value: (percent: number) => string } diff --git a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts index b82721e0a6..e1f236c578 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/apply.client.spec.ts @@ -47,6 +47,7 @@ interface Recorded { async function boot() { const ctx = new Context() + ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag']) ctx.provide('resources', createResources()) ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never) const tabs = new SidebarRightTabRegistry(ctx) diff --git a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx index da67649ce5..60201139d6 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/document-seat.client.spec.tsx @@ -38,6 +38,7 @@ afterEach(async () => { async function boot() { const rt = await SlotTestRuntime.create() + rt.ctx.provide('fileDrag', { actions: { prepare: async () => ({ start: () => true, release: () => {} }), reportFailure: () => {} }, read: () => undefined } satisfies import('@deepseek-ai/cordis').Context['fileDrag']) runtime = rt rt.ctx.provide('configForms', { developerTools: { enabled: createSnapshotStore(true) } } as never) rt.ctx.provide('layout', { openRightbar: vi.fn(), closeRightbar: vi.fn(), panelInfo: rt.panelInfo } as never) diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx b/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx index 89b23d756d..85e4bc3033 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx +++ b/packages/client/ui-sidebar-documentpreview/tests/image-body.client.spec.tsx @@ -43,6 +43,7 @@ function props(path = 'asset.png', data: Uint8Array = new Uint8Arra return selector(useSyncExternalStore(subscribe, snapshot)) } return { + fileDrag, resourceAddress: `dsh-resource://file/session/image/${path}`, content: { kind: 'bytes', data }, wrap: false, @@ -83,13 +84,13 @@ describe('ImageBody', () => { expect(revoke).toHaveBeenCalledExactlyOnceWith('blob:https://preview.invalid/1') }) - it('keeps the image non-draggable, then shows it after decoding succeeds', async () => { + it('allows a contained image to drag, then shows it after decoding succeeds', async () => { render() const image = await screen.findByRole('img', { hidden: true }) Object.defineProperty(image, 'naturalWidth', { configurable: true, value: 50 }) expect(image.getAttribute('alt')).toBe('Image preview: photo.svg') expect(image.getAttribute('decoding')).toBe('async') - expect(image.getAttribute('draggable')).toBe('false') + expect(image.getAttribute('draggable')).toBe('true') expect(image.getAttribute('referrerpolicy')).toBe('no-referrer') expect(image.parentElement?.hasAttribute('hidden')).toBe(true) fireEvent.load(image) @@ -151,3 +152,5 @@ describe('ImageBody', () => { expect(instance.getSnapshot().byTab['image-tab' as TabId]).toEqual({ kind: 'fit-width' }) }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-pan.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/image-pan.client.spec.ts new file mode 100644 index 0000000000..8ebf15cf34 --- /dev/null +++ b/packages/client/ui-sidebar-documentpreview/tests/image-pan.client.spec.ts @@ -0,0 +1,137 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { bindImagePan } from '../src/client/image/pan.ts' + +afterEach(() => { document.body.replaceChildren(); vi.unstubAllGlobals() }) + +function viewport(width: number, height: number) { + const scrollport = document.createElement('div') + scrollport.dataset.documentZoomScrollport = '' + Object.defineProperties(scrollport, { + clientWidth: { value: 400 }, clientHeight: { value: 300 }, + scrollWidth: { configurable: true, value: width }, scrollHeight: { configurable: true, value: height }, + }) + const image = document.createElement('img') + scrollport.append(image) + document.body.append(scrollport) + let captured: number | undefined + image.setPointerCapture = (id) => { captured = id } + image.hasPointerCapture = id => captured === id + image.releasePointerCapture = () => { captured = undefined } + return { scrollport, image, captured: () => captured } +} +function pointer(image: HTMLImageElement, type: string, values: Partial = {}) { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, buttons: 1, ...values }) + Object.defineProperties(event, { pointerId: { value: values.pointerId ?? 1 }, pointerType: { value: values.pointerType ?? 'mouse' } }) + image.dispatchEvent(event) + return event +} + +it('pans a fit-width long image on its overflowing axis and leaves no pointer capture after release', () => { + vi.stubGlobal('ResizeObserver', undefined) + const { image, scrollport, captured } = viewport(400, 900) + const dispose = bindImagePan(image) + try { + expect(image.draggable).toBe(false) + expect(image.dataset.pan).toBe('available') + expect(pointer(image, 'pointerdown', { clientX: 200, clientY: 200 }).defaultPrevented).toBe(true) + pointer(image, 'pointermove', { clientX: 100, clientY: 100 }) + expect(scrollport.scrollLeft).toBe(0) + expect(scrollport.scrollTop).toBe(100) + expect(captured()).toBe(1) + pointer(image, 'pointerup') + expect(captured()).toBeUndefined() + pointer(image, 'pointermove', { clientY: 0 }) + expect(scrollport.scrollTop).toBe(100) + } finally { dispose() } +}) + +it('clamps both axes, reverses immediately at an edge, and releases on blur', () => { + const { image, scrollport, captured } = viewport(800, 600) + const dispose = bindImagePan(image) + try { + pointer(image, 'pointerdown', { clientX: 100, clientY: 100 }) + pointer(image, 'pointermove', { clientX: -500, clientY: -500 }) + expect([scrollport.scrollLeft, scrollport.scrollTop]).toEqual([400, 300]) + pointer(image, 'pointermove', { clientX: -490, clientY: -490 }) + expect([scrollport.scrollLeft, scrollport.scrollTop]).toEqual([390, 290]) + window.dispatchEvent(new Event('blur')) + expect(captured()).toBeUndefined() + expect(image.dataset.pan).toBe('available') + } finally { dispose() } +}) + +it('lets contained images drag as files, and switches mode when their viewport changes', () => { + const { image, scrollport } = viewport(400, 300) + let resize = () => {} + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe() {} + disconnect = disconnect + }) + const dispose = bindImagePan(image) + expect(image.draggable).toBe(true) + expect(pointer(image, 'pointerdown').defaultPrevented).toBe(false) + Object.defineProperty(scrollport, 'scrollHeight', { value: 600 }) + resize() + expect(image.draggable).toBe(true) + pointer(image, 'pointerup') + expect(image.draggable).toBe(false) + pointer(image, 'pointerdown', { pointerType: 'touch' }) + expect(image.dataset.pan).toBe('available') + dispose() + expect(disconnect).toHaveBeenCalledOnce() +}) + +it('rejects a missing scrollport with an explicit error', () => { + expect(() => bindImagePan(document.createElement('img'))).toThrow('Image panning requires a document zoom scrollport') +}) + +it('ignores unrelated pointers and releases capture when the mouse button is no longer held', () => { + const { image, scrollport, captured } = viewport(800, 600) + const dispose = bindImagePan(image) + try { + pointer(image, 'pointerdown', { button: 2 }) + expect(captured()).toBeUndefined() + pointer(image, 'pointerdown', { clientX: 100, clientY: 100 }) + pointer(image, 'pointerup', { pointerId: 2 }) + pointer(image, 'pointermove', { pointerId: 2, clientX: 0 }) + expect(captured()).toBe(1) + expect(scrollport.scrollLeft).toBe(0) + pointer(image, 'pointermove', { buttons: 0 }) + expect(captured()).toBeUndefined() + expect(image.dataset.pan).toBe('available') + } finally { dispose() } +}) + +it('blocks native dragging only for a pan and retains the selected mode until release', () => { + const { image, scrollport } = viewport(400, 300) + const dispose = bindImagePan(image) + const drag = () => { + const event = new Event('dragstart', { cancelable: true }) + image.dispatchEvent(event) + return event.defaultPrevented + } + try { + expect(drag()).toBe(false) + pointer(image, 'pointerdown') + pointer(image, 'pointermove', { clientX: 30 }) + Object.defineProperty(scrollport, 'scrollWidth', { value: 800 }) + expect(drag()).toBe(false) + image.dispatchEvent(new Event('dragend')) + expect(drag()).toBe(true) + pointer(image, 'pointerdown') + expect(drag()).toBe(true) + pointer(image, 'pointercancel') + expect(image.dataset.pan).toBe('available') + } finally { dispose() } +}) + +it('does not end an idle pan for a foreign pointer release', () => { + const { image } = viewport(800, 600) + const dispose = bindImagePan(image) + pointer(image, 'pointerup', { pointerId: 2 }) + expect(image.dataset.pan).toBe('available') + dispose() +}) diff --git a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts index 46b75ff751..63e428e35f 100644 --- a/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts +++ b/packages/client/ui-sidebar-documentpreview/tests/image-registration.client.spec.ts @@ -47,6 +47,7 @@ describe('image registration', () => { entries.push(options) return () => { bodies.delete(options.key); entries.splice(entries.indexOf(options), 1) } }) + ctx.provide('fileDrag', { actions: fileDrag, read: () => undefined }) ctx.provide('documentPreviews', registry) ctx.provide('slots', { inject: (_key: string, callback: () => () => void) => callback(), register } as never) ctx.provide('locale', { @@ -83,3 +84,5 @@ describe('image registration', () => { expect(dictionaries.size).toBe(0) }) }) + +const fileDrag = { prepare: vi.fn(async () => ({ start: () => true, release: () => {} })), reportFailure: vi.fn() } diff --git a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json index b0aa954b66..113df7ae07 100644 --- a/packages/client/ui-sidebar-documentpreview/tsconfig.client.json +++ b/packages/client/ui-sidebar-documentpreview/tsconfig.client.json @@ -67,6 +67,9 @@ }, { "path": "../ui-settings/tsconfig.client.json" + }, + { + "path": "../ui-file-drag" } ] } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css index 853e24953a..0c9f9046cb 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.module.css @@ -67,6 +67,18 @@ transition: transform var(--ds-transition-duration-slow) var(--ds-ease-in-out); } +/* An item mounted by the opening has no closed style to slide from; it starts + at the closed position like the items already mounted. Only an opening + marks the panel, so items that mount while it stays open, or that a + Session switch shows, appear in place. */ +@starting-style { + .panel[data-sidebar-right-opening] :global([data-dockkit-host='dock']), + .panel[data-sidebar-right-opening] :global([data-dockkit-empty]), + .panel[data-sidebar-right-opening] :global([data-dockkit-divider]) { + transform: translateX(var(--dsh-sidebar-width)); + } +} + .panel[data-sidebar-right-panel='fullscreen'] { --dsh-dockkit-dock-layer: 40; } @@ -110,7 +122,10 @@ @media (prefers-reduced-motion: reduce) { .panel :global([data-dockkit-host='dock']), .panel :global([data-dockkit-empty]), - .panel :global([data-dockkit-divider]) { + .panel :global([data-dockkit-divider]), + .panel[data-sidebar-right-open] :global([data-dockkit-host='dock']), + .panel[data-sidebar-right-open] :global([data-dockkit-empty]), + .panel[data-sidebar-right-open] :global([data-dockkit-divider]) { transition: none; } } diff --git a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx index 484dfe458b..0f39af5d9f 100644 --- a/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx +++ b/packages/client/ui-sidebar-right/src/client/shell/SidebarRight.tsx @@ -8,9 +8,11 @@ * * Docked content stays mounted while collapsed, translated off the frame's right * edge, so opening and closing are one gesture in both presentations: a slide - * from and to that edge. Normal presentation moves the frame's tracks with - * the panel. A fullscreen opening reserves its underlying track only after - * the panel covers the frame, without animating those hidden columns. + * from and to that edge. An item mounted by the opening, such as the first tab + * of an empty column, starts at that edge, so it joins the slide. Normal + * presentation moves the frame's tracks with the panel. A fullscreen opening + * reserves its underlying track only after the panel covers the frame, without + * animating those hidden columns. * * The panel has no header of its own: its two controls — presentation switch * and collapse — ride the docking kit's chrome seat at the end of the top-right @@ -27,7 +29,7 @@ * signal, actions — is read through the slot-owned useTabInfo hook. The Tab * domain follows each session's store commits, including sessions off screen. */ -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { CSSProperties, ReactNode, RefObject } from 'react' import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client' import { IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' @@ -303,6 +305,17 @@ function SidebarPanel(panel: PanelProps & { width: number; panelRef: RefObject value) + // True from the render that opens the on-screen panel until a transition in + // it ends, the panel closes, or it leaves the screen, which cancels the + // slide; the stylesheet starts items mounted meanwhile at the closed position. + const [opened, setOpened] = useState(expanded) + const [opening, setOpening] = useState(false) + if (opened !== expanded) { + setOpened(expanded) + setOpening(expanded && panel.active) + } else if (opening && !panel.active) { + setOpening(false) + } return (
{ setOpening(false) } : undefined} // Dockkit hides individual docked hosts; floating descendants remain accessible. aria-hidden={(!expanded && surface.layout.floats.length === 0) || undefined} > diff --git a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx index facd9870b2..9d20ce5ba2 100644 --- a/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx +++ b/packages/client/ui-sidebar-right/tests/seat.client.spec.tsx @@ -557,6 +557,33 @@ describe('RightbarSeat presentation', () => { expect(h.frame.closeRightbar).toHaveBeenCalled() }) + it('marks the panel as opening from the opening render until its slide ends', async () => { + const h = await mountSeat() + const panel = element(h.view.container, '[data-sidebar-right-panel]') + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false) + h.open() + // The tab the opening seeds mounts under the mark, so it starts at the closed position. + const host = element(h.view.container, "[data-dockkit-host='dock']") + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(true) + fireEvent.transitionEnd(host) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false) + act(() => { h.actions.splitPane(SESSION) }) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false) + fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]')) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false) + act(() => { h.actions.setExpanded(SESSION, true) }) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(true) + // Closing before the slide ends clears the mark. + fireEvent.click(element(h.view.container, '[data-sidebar-right-toggle]')) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false) + // Leaving the screen cancels the slide, so returning shows the items in place. + act(() => { h.actions.setExpanded(SESSION, true) }) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(true) + act(() => { h.runtime.panelInfo.set({ activePanelId: 'other-panel' as MainPanelId }) }) + act(() => { h.runtime.panelInfo.set({ activePanelId: null }) }) + expect(panel.hasAttribute('data-sidebar-right-opening')).toBe(false) + }) + it('fills the viewport without replacing the content tree or releasing the wide track', async () => { const h = await mountSeat() const tab = h.open() diff --git a/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts b/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts new file mode 100644 index 0000000000..378c72834e --- /dev/null +++ b/packages/client/ui-sidebar-right/tests/sidebar-right-styles.client.spec.ts @@ -0,0 +1,20 @@ +/** Right sidebar slide style contracts. */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/shell/SidebarRight.module.css', import.meta.url)), 'utf8') + .replace(/\/\*[\s\S]*?\*\//g, ' ') +const host = String.raw`:global\(\[data-dockkit-host='dock'\]\)` + +describe('SidebarRight.module.css', () => { + it('starts docked items mounted by an opening at the closed position', () => { + expect(css).toMatch(new RegExp(String.raw`@starting-style\s*\{\s*\.panel\[data-sidebar-right-opening\] ${host},[^{}]*\{` + + String.raw`\s*transform: translateX\(var\(--dsh-sidebar-width\)\);\s*}\s*}`)) + }) + + it('removes the open slide as well as the close slide for reduced motion', () => { + expect(css).toMatch(new RegExp(String.raw`@media \(prefers-reduced-motion: reduce\)\s*\{[^{}]*` + + String.raw`\.panel\[data-sidebar-right-open\] ${host}[^{}]*\{\s*transition: none;\s*}`)) + }) +}) diff --git a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap index 7793c6c99e..433bcba048 100644 --- a/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap +++ b/packages/client/ui-sidebar/tests/__snapshots__/sidebar-snapshot.client.spec.tsx.snap @@ -21,13 +21,10 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
renders Windows caption controls in expanded > @@ -169,13 +163,10 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded
@@ -192,13 +183,10 @@ exports[`sidebar shell snapshots > renders Windows caption controls in expanded > renders the collapsed rail after the crossfad
renders the collapsed rail after the crossfad > @@ -427,13 +409,10 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul
@@ -450,13 +429,10 @@ exports[`sidebar shell snapshots > renders the expanded column (wordmark, capsul > renders the expanded column in the default lo
@@ -599,13 +572,10 @@ exports[`sidebar shell snapshots > renders the expanded column in the default lo > { expect(row.getAttribute('role')).toBeNull() expect(view.container.textContent).toContain('正在加载 skill') expect(view.container.textContent).toContain('dsh-manage-issues') - expect(view.container.querySelector('svg [fill="currentColor"]')).not.toBeNull() + expect(view.container.querySelector('svg[data-icon="dsw-icon-skill-outline-regular"]')).not.toBeNull() }) it('uses the first failure line in the summary and exposes the full error', () => { diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 8856253c0b..1b86d7c1dd 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -24,8 +24,8 @@ en: b45d5b48ca799c3a zh: af65514b3759204f /deepseek-ai-dsh-client-ui-theme/use-this-package/registering-a-theme: - en: 2cd32ccaba33b3e9 - zh: 53ea78d19929ea02 + en: 5244fc168d0a03f8 + zh: c7ab12fdf8c6c55b /deepseek-ai-dsh-client-ui-theme/use-this-package/pre-plugin-palette: en: 203523ddd7927c34 zh: 31dd89da78f9ab4f @@ -51,8 +51,8 @@ en: ca75c51c89c2c9b0 zh: de54f3467ef3b9be /deepseek-ai-dsh-client-ui-theme/known-limitations-and-deferred-work: - en: 0d328dbced31a041 - zh: 2951de356ba6bb82 + en: 2388061abb1dd40f + zh: 1a4ad9a8e12472df /deepseek-ai-dsh-client-ui-theme/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index d67099cb8e..2e65e8be04 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -41,7 +41,7 @@ Three rows set the text font (interface and conversation text), the code font (c ### Registering a theme -A composition can register a third-party theme id with alias-token overrides through `ctx.theme`; the override layer folds into the active snapshot's tokens in registration order. Removing one never overwrites the last durable built-in preference. Third-party theme ids remain an in-process extension and do not cross the built-in settings schema. +A composition can register a third-party theme id with alias-token overrides through `ctx.theme`; the override layer folds into the active snapshot's tokens in registration order. A theme can also replace product icons: `ThemeDefinition.icons` maps ui-primitives `ICON_SYMBOLS` ids to complete SVG documents. The plugin keeps one hidden icon sprite at the start of `body` whose `` children follow the active theme's replacements and fall back to the defaults, so a replacement may be multi-color and may paint with `currentColor` or `--dsw-*` variables that follow the palette. Replacement markup is trusted like the registering plugin's code. `register` rejects unknown ids, documents without a well-formed SVG-namespace `` root and `viewBox`, `

{expanded || preview === undefined ? text : preview}

@@ -62,22 +77,32 @@ function FoldedText({ text, expandLabel, collapseLabel }: { text: string; expand } -/** Place one card after the complete answer. - * @param props - Completed turn and its session-bound proposals. - * @returns the first proposal, or null. +/** The projection admits at most one proposal per turn; should several exist, the lowest call id is shown. */ +function proposalOf(view: CreationView | undefined, turn: number): CreationRecommendation | undefined { + let found: CreationRecommendation | undefined + for (const proposal of Object.values(view?.proposals ?? {})) { + if (proposal.turn === turn && (found === undefined || proposal.callId < found.callId)) found = proposal + } + return found +} + +/** Place the proposal admitted in a turn after that turn's final answer, once the turn has ended. + * @param props - Turn location, projection reader and session-bound actions. + * @returns the creation card, or null. */ // Each optional bundle owns its card and locale contracts; this small tail wrapper does not warrant a shared package. /* jscpd:ignore-start */ -export function RecommendationTail({ turn, useRecommendations, ...props }: Pick, 'turn' | 'useProjection'> - & PropsLocale<'creationRecommendations'> & InjectFace) { - const recommendation = useRecommendations(String(turn.turn))?.[0] - const phase = props.useProjection('creationRecommendations')?.decisions[recommendation?.callId ?? '']?.phase - return recommendation === undefined || phase === 'suppressed' || turn.end?.data.reason.kind !== 'completed' || turn.data.get('turn-tail')?.closing == null +export function RecommendationTail({ turn, ...props }: Pick, 'turn' | 'useProjection'> + & PropsLocale<'creationRecommendations'> & InjectFace) { + const view = props.useProjection('creationRecommendations') + const recommendation = proposalOf(view, turn.turn) + const phase = view?.decisions[recommendation?.callId ?? '']?.phase + return recommendation === undefined || phase === 'suppressed' || turn.end === undefined || turn.data.get('turn-tail')?.closing == null ? null : } /* jscpd:ignore-end */ -/** Display creation, persistent installation, and reversible removal. +/** Display creation, persistent installation, reversible removal and restoration. * @param props - Proposal, receipts and human actions. * @returns the creation card. */ @@ -90,16 +115,17 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo const infoDismissed = useInfoDismissed(value => value) const infoId = useId() const customId = useId() - const [pending, setPending] = useState(false) + const [pending, setPending] = useState() const [error, setError] = useState() + const busy = pending !== undefined const slot = selected !== undefined && selected !== 'custom' ? selected : decision?.slot ?? recommendation.slot const location = selected ?? (decision?.customLocation === undefined ? slot : 'custom') const customLocation = custom ?? decision?.customLocation ?? '' const offer = decision === undefined || decision.phase === 'cancelled' || decision.phase === 'failed' || decision.phase === 'interrupted' || decision.phase === 'unconfirmed' const infoOpen = info ?? (offer && !infoDismissed) const installed = decision?.phase === 'installed' || decision?.phase === 'kept' - const run = async (action: 'create' | 'dismiss' | 'keep' | 'undo' | 'cancel') => { - setPending(true) + const run = async (action: CardAction) => { + setPending(action) setError(undefined) if (action === 'create' || action === 'dismiss') { dismissInfo(); setInfo(false) } // Independent optional cards keep their action and receipt handling local. @@ -111,7 +137,7 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo } catch { // A lost reply does not prove failure; replay supplies the durable receipt. setError('transport') - } finally { setPending(false) } + } finally { setPending(undefined) } /* jscpd:ignore-end */ } return
@@ -122,7 +148,7 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo setInfo(!infoOpen) if (infoOpen) dismissInfo() }}> - + @@ -141,36 +167,41 @@ export function RecommendationCard({ recommendation, useProjection, act, useInfo {t('initialContent')}
} - {decision?.application !== undefined && decision.application !== 'applied' - &&

{t(decision.application)}

} + {decision?.application !== undefined && decision.application !== 'applied' && decision.phase !== 'failed' + &&

{t(APPLICATION_KEYS[decision.application])}

} {error !== undefined &&

{t(error)}

} {offer ?
{t('location')} - +
{location === 'custom' &&
-