Merge master into fix/summary-overflow-recovery

This commit is contained in:
1tops committed 2026-10-23 11:39:37 +08:00
commit 237fbc02ac
296 files changed
+5112 -1290

No files matched your search

@@ -9,8 +9,8 @@
en: 2eeb916eb65c66e7
zh: 7bf5c0abb8bb61e4
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/decision:
en: 42e800c4cd3b6ebe
zh: 06fabe5a9681fa27
en: 0125e33ba966c8ac
zh: bf6af389336999af
/agent-note-deepseek-account-models-come-from-the-account-listing-and-gate-by-client-version/alternatives-considered:
en: 7c7880fdb0cc95f6
zh: 2b8a539192626789
@@ -12,8 +12,8 @@ The account route's catalog was deployment configuration: a build advertised the
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.md) loads the listing lazily for each credential, waits for it once up to `modelsTimeoutMs`, refreshes it in the background after `modelsMaxAgeMs`, keeps the last good listing when a load fails or returns no model, and serves the bundled catalog before any listing loads. The cache lives in memory and is keyed by endpoint and token fingerprint, so per-account rollouts stay separate.
- A listed model is available when its `client_requirements["deepseek-harness"].min_version` is at most the current DeepSeek Harness version in semantic-version order. Without that declaration, only ids the built-in catalog knows are available; the others stay hidden, because a client cannot tell which future version supports them. A request for a refused listed model fails before network I/O with `CLIENT_UPGRADE_REQUIRED` or `MODEL_UNSUPPORTED`.
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge such as tool-update mode and image budgets; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
- The shared transport gains only two provider hooks, `connect` and `admit`; listing, gating, and merging stay in the account package.
- Listed capabilities replace bundled ones field by field; the bundled entry keeps client-side knowledge the listing does not declare, such as image budgets and a protocol mode the listing omits or sets to `null`; `modelOverrides` applies last and reuses the `models` entry fields and validation. `max_output_tokens` bounds the request default instead of becoming it.
- The shared transport gains only two provider hooks, `connect` and `admit`. Listing, gating, and merging live in the shared transport, which the [official route](2026-10-22-deepseek-official-model-listing.md) also uses.
## Alternatives considered
@@ -12,8 +12,8 @@ Status: implemented
- [`llm-deepseek-account`](../../../../packages/llm/llm-deepseek-account/README.zh.md) 按凭据懒加载列表,首次访问最多等待 `modelsTimeoutMs`,超过 `modelsMaxAgeMs` 后在后台刷新;获取失败或返回的列表中没有模型时保留上一份,还没有加载到任何列表时使用内置目录。缓存只在内存中,以端点和 token 指纹为键,因此按账号灰度互不干扰。
- 列出的模型在其 `client_requirements["deepseek-harness"].min_version` 按 semver 顺序不高于当前 DeepSeek Harness 版本时可用。没有这个声明时,只有内置目录认识的 id 可用,其余保持隐藏,因为客户端无法判断将来哪个版本会支持它们。请求被拒绝的已列出模型时,在网络请求之前以 `CLIENT_UPGRADE_REQUIRED` 或 `MODEL_UNSUPPORTED` 失败。
- 列表中的能力逐字段覆盖内置能力;内置条目保留工具更新模式、图片预算等客户端知识;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子;列表、门控与合并都留在账号包中。
- 列表中的能力逐字段覆盖内置能力;内置条目保留列表没有声明的客户端知识,例如图片预算,以及列表没有给出或设为 `null` 的协议模式;`modelOverrides` 最后叠加,复用 `models` 条目的字段与校验。`max_output_tokens` 只限制请求默认值,不直接成为默认值。
- 共享传输层只新增 `connect` 与 `admit` 两个提供方钩子。列表、门控与合并位于共享传输包中,[官方路由](2026-10-22-deepseek-official-model-listing.zh.md)也使用它们。
## 考虑过的替代方案
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-22-deepseek-official-model-listing.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-22-deepseek-official-model-listing.md
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized:
en: 20de6c2a1bc643b4
zh: 2fe21450cebf7f32
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/problem:
en: 51a513e6134e6ad9
zh: e0b19412c9b88e24
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/decision:
en: ceb2f960085577c3
zh: 1bbc833b7cf24070
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/alternatives-considered:
en: fa126e4f4e0790b4
zh: c4f6d266d1ba6753
/agent-note-the-official-deepseek-route-follows-the-official-model-listing-until-its-catalog-is-customized/consequences:
en: 047e30a0f55acb24
zh: 804e15532d172157
@@ -0,0 +1,36 @@
# Agent Note: The official DeepSeek route follows the official model listing until its catalog is customized
Status: implemented
English | [中文](2026-10-22-deepseek-official-model-listing.zh.md)
## Problem
The `deepseek-official` route offered whatever its `models` setting held, and that setting defaults to the catalog bundled with each build. A model the DeepSeek API added reached API-key users only through a release, while the account route already followed the service's `GET /models` listing at the same origin. Unlike the account route's catalog, the official route's catalog is user-editable: the Models page stores an edited catalog in `models`, and a gateway behind `baseURL` declares its models there.
## Decision
- [`llm-deepseek-api-key`](../../../../packages/llm/llm-deepseek-api-key/README.md) follows `GET https://api.deepseek.com/models`, authenticated with the API key in `x-api-key`, only while the Messages origin is `https://api.deepseek.com` and `models` equals the built-in catalog. Any other endpoint or catalog sends no listing request.
- Loading, refresh, fallback, merging, and the client-version gate are the [account route's](2026-09-24-deepseek-account-remote-model-catalog.md): a first access waits up to `modelsTimeoutMs`, a listing older than `modelsMaxAgeMs` refreshes in the background on the next access, a failure keeps the last good listing or the built-in catalog, and the cache lives in memory, keyed by endpoint and key fingerprint.
- A customized `models` list wins whole: it replaces the catalog and stops listing requests. A list is customized when it resolves differently from the built-in catalog, because the schema default materializes an absent `models` as the built-in list and the Models page shows that default as its inherited rows.
- Switching between a customized catalog and the listing invalidates the cache from a `loader/volatile-update` listener, so Restore defaults reloads the listing on the next access even when the cached listing is fresh.
- The listing request, its cache, catalog resolution, and the gate live in the shared [Messages transport](../../../../packages/llm/llm-deepseek/README.md) because two routes consume them; the transport's provider hooks remain `connect` and `admit`.
## Alternatives considered
**Follow the listing on every endpoint.** A gateway that publishes an OpenAI-style `/models` returns entries without `api_capabilities.anthropic_messages`, which the decoder hides, so the route would offer no models. Every such gateway would also receive an extra request carrying the key.
**Merge the customized catalog with the listing by model id.** Customized users would keep receiving new listed models, but a model the user deleted would return, and the Models page cannot hide one listed model.
**Mark an unset `models` by removing its schema default.** An absent value would select the listing without comparing catalogs, but the Models page would lose the inherited rows it shows and copies, with their hidden `systemPromptUpdate` and `toolUpdate` fields, into a customized catalog. Rows copied from the generic model-discovery API would drop those fields.
**Fetch once per process, or refresh on a timer.** A single fetch leaves a long-running Web or desktop host on its startup catalog, and one transient failure pins it to the built-in catalog. A timer sends requests while the route is idle. Access-triggered background refresh matches the account route.
**Reuse the cached listing after Restore defaults.** The cache could serve a listing loaded before the customization for up to `modelsMaxAgeMs`; the request is cheap, so restoring always reloads it. The reload starts on the next access rather than inside the listener, which keeps credential lookup and its failures on the request path; the Web page's settings refresh makes that access right after the save.
## Consequences
- API-key users on the official endpoint receive listed models without a release, and an older build hides or refuses a model that needs a newer client, as account users' builds do.
- A customized catalog receives no later listed models until Restore defaults, and a `models` list identical to the built-in catalog counts as uncustomized. The comparison uses the running version's built-in catalog, so a saved list equal to an earlier version's built-in catalog counts as customized once the built-in catalog changes.
- The Models page shows the built-in rows while the listing is followed, so a model only the listing offers appears only in model selectors, and editing the page's catalog drops it.
- The first catalog access on the official endpoint can wait up to `modelsTimeoutMs`, and a suite that boots the route there must answer the listing itself to stay off the network.
@@ -0,0 +1,36 @@
# Agent Note: DeepSeek 官方路由在目录未自定义时跟随官方模型列表
Status: implemented
[English](2026-10-22-deepseek-official-model-listing.md) | 中文
## 问题
`deepseek-official` 路由提供的是 `models` 设置里的内容,而这个设置默认是每个构建自带的目录。DeepSeek API 新增的模型只能随发版到达 API key 用户,而账号路由已经在同一个 origin 上跟随服务端的 `GET /models` 列表。与账号路由不同,官方路由的目录可以由用户编辑:模型页把编辑后的目录存进 `models`,`baseURL` 后面的网关也在这里声明自己的模型。
## 决策
- [`llm-deepseek-api-key`](../../../../packages/llm/llm-deepseek-api-key/README.zh.md) 只在 Messages 的 origin 是 `https://api.deepseek.com`、且 `models` 等于内置目录时,跟随 `GET https://api.deepseek.com/models`,并用 `x-api-key` 携带 API key 鉴权。其他端点或目录不发列表请求。
- 加载、刷新、兜底、合并与客户端版本门控沿用[账号路由](2026-09-24-deepseek-account-remote-model-catalog.zh.md)的规则:首次访问最多等待 `modelsTimeoutMs`,列表超过 `modelsMaxAgeMs` 后在下一次访问时后台刷新,失败时保留上一份成功的列表或使用内置目录,缓存只在内存中,以端点和 key 指纹为键。
- 自定义的 `models` 列表整份优先:它替换目录,并停止列表请求。解析结果与内置目录不同的列表才算自定义,因为 schema 默认值会把缺省的 `models` 物化为内置列表,模型页也把这个默认值显示为继承的行。
- 在自定义目录与列表之间切换时,一个 `loader/volatile-update` 监听器会让缓存失效,因此点恢复默认后,即使缓存的列表尚未过期,下一次访问也会重新加载。
- 列表请求、缓存、目录解析和门控放在共享的 [Messages 传输包](../../../../packages/llm/llm-deepseek/README.zh.md)中,因为现在有两条路由使用它们;传输层的提供方钩子仍然只有 `connect` 和 `admit`。
## 考虑过的替代方案
**在所有端点上跟随列表。** 发布 OpenAI 风格 `/models` 的网关返回的条目没有 `api_capabilities.anthropic_messages`,解码器会隐藏它们,路由就不提供任何模型。每个这样的网关还会多收到一个携带 key 的请求。
**按模型 id 合并自定义目录与列表。** 自定义用户会继续收到新列出的模型,但用户删掉的模型会重新出现,而模型页无法隐藏某一个列出的模型。
**去掉 `models` 的 schema 默认值来标记未设置。** 缺省值不用比较目录就能选中列表,但模型页会失去它显示的继承行;这些行连同隐藏的 `systemPromptUpdate`、`toolUpdate` 字段会被复制进自定义目录。从通用模型发现 API 复制的行会丢掉这些字段。
**每个进程只获取一次,或按定时器刷新。** 只获取一次会让常驻的 Web 或桌面宿主停留在启动时的目录,一次偶发失败就会让它固定在内置目录。定时器会在路由空闲时发请求。由访问触发的后台刷新与账号路由一致。
**恢复默认后沿用缓存的列表。** 缓存可能在 `modelsMaxAgeMs` 内一直提供自定义之前加载的列表;列表请求代价很小,所以恢复默认总是重新加载。重新加载从下一次访问开始,而不是在监听器里进行,这样凭据查询及其失败都留在请求路径上;Web 页面保存后的设置刷新会立即发起这次访问。
## 影响
- 使用官方端点的 API key 用户无需发版就能看到列出的模型,旧构建会像账号用户的构建一样隐藏或拒绝需要新版客户端的模型。
- 自定义目录在恢复默认之前收不到之后列出的模型;与内置目录完全相同的 `models` 列表视为未自定义。比较的是当前版本的内置目录,所以保存的列表若等于旧版本的内置目录,内置目录变化后就算作自定义。
- 跟随列表期间,模型页仍显示内置的行,因此只有列表提供的模型只出现在模型选择器里,在模型页编辑目录会丢掉它。
- 官方端点上的第一次目录访问可能等待最多 `modelsTimeoutMs`,在该端点上启动路由的测试套件必须自己应答列表请求,才能不访问网络。
+6
View File
@@ -443,6 +443,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
}, SPAWN_TIMEOUT_MS + 30_000)
it('serves the SDK protocol with an absolute-path overlay plugin and exits after shutdown', async () => {
// On the official endpoint the route requests its model listing with this key;
// the loopback mock keeps the run offline and records any model call.
const server = await startMockLlmServer({ sequence: ['success'] })
const home = mkdtempSync(join(tmpdir(), 'dsh-built-sdk-'))
const pluginPath = join(home, 'plugin #100%.mjs')
const marker = join(home, 'plugin-loaded')
@@ -465,6 +468,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
DSH_HOME: home,
DSH_TELEMETRY_DISABLED: '1',
DEEPSEEK_API_KEY: 'built-sdk-profile-no-call',
DEEPSEEK_BASE_URL: server.baseURL,
},
extendEnv: false,
})
@@ -504,10 +508,12 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
expect(result.signal, stderr).toBeUndefined()
expect(result.exitCode, stderr).toBe(0)
expect(stderr).toBe('')
expect(server.requests).toEqual([])
expect(readFileSync(marker, 'utf8')).toBe('loaded')
} finally {
child.kill('SIGKILL')
await child
await server.close()
rmSync(home, { recursive: true, force: true })
}
}, SPAWN_TIMEOUT_MS + 30_000)
@@ -4,7 +4,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Probe strict-schema fillers against missing-goal revision 1, then create a durable goal to finish the snapshot proof and inspect it."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Probe strict-schema fillers against miss","messageSeqs":[8],"source":{"kind":"fallback"}}}}
@@ -4,7 +4,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}}
@@ -4,7 +4,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"say pong"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":1000000}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"say pong","messageSeqs":[8],"source":{"kind":"fallback"}}}}
@@ -4,7 +4,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"retry the transient provider failure"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"retry the transient provider failure","messageSeqs":[8],"source":{"kind":"fallback"}}}}
@@ -18,7 +18,7 @@
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[3],"source":{"kind":"fallback"}}}
@@ -16,7 +16,7 @@
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Start a background job.","messageSeqs":[3],"source":{"kind":"fallback"}}}
@@ -16,7 +16,7 @@
{"type":"step/start","data":{"turn":2,"step":1}}
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash","reasoningEffort":"low"},"tools":["bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","read","read_image","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"resume","startsSeries":true}}
{"type":"developer/message","data":{"turn":2,"step":1,"message":{"source":{"kind":"tool-registry"},"content":[{"type":"tool-addition","toolName":"bash"},{"type":"tool-addition","toolName":"create_goal"},{"type":"tool-addition","toolName":"edit"},{"type":"tool-addition","toolName":"get_goal"},{"type":"tool-addition","toolName":"glob"},{"type":"tool-addition","toolName":"grep"},{"type":"tool-addition","toolName":"interrupt_agent"},{"type":"tool-addition","toolName":"job_kill"},{"type":"tool-addition","toolName":"job_list"},{"type":"tool-addition","toolName":"job_output"},{"type":"tool-addition","toolName":"list_agents"},{"type":"tool-addition","toolName":"read"},{"type":"tool-addition","toolName":"read_image"},{"type":"tool-addition","toolName":"send_message"},{"type":"tool-addition","toolName":"skill"},{"type":"tool-addition","toolName":"subagent"},{"type":"tool-addition","toolName":"subagent_fork"},{"type":"tool-addition","toolName":"todo_write"},{"type":"tool-addition","toolName":"update_goal"},{"type":"tool-addition","toolName":"web_fetch"},{"type":"tool-addition","toolName":"web_search"},{"type":"tool-addition","toolName":"workflow"},{"type":"tool-addition","toolName":"working_directory"},{"type":"tool-addition","toolName":"write"}],"role":"developer","id":"{{sessionId}}"},"headerSeq":18},"surfaceOp":"append"}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
@@ -4,7 +4,7 @@
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"system/message","seq":7,"time":0,"data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Start one continuable subagent and acknowledge its completion notice. Do not call list_agents or send_message."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}
{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":12,"time":0,"data":{"title":"Start one continuable subagent and","messageSeqs":[8],"source":{"kind":"fallback"}}}}
@@ -17,7 +17,7 @@
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}}
@@ -17,7 +17,7 @@
{"type":"system/message","data":{"turn":2,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{sessionId}}"}},"sourceEventSeqs":[2],"surfaceOp":{"op":"replace","startSeq":2,"endSeq":2}}
{"type":"user/message","data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"<system-reminder>\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n</system-reminder>"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent working directory: \"{{cwd}}\".\n\nCurrent DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable.\n\nApproval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"working-directory:current","text":"Current working directory: \"{{cwd}}\"."},{"name":"sandbox:policy","text":"Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: \"{{cwd}}\". Some platform temporary areas may also be writable."},{"name":"approval:policy","text":"Approval prompts are enabled in this session: operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}
{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial","startsSeries":true}}
{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
{"type":"session/title","data":{"title":"Remember the workspace instruction.","messageSeqs":[3],"source":{"kind":"fallback"}}}
@@ -0,0 +1,110 @@
// Browser hit testing for the conversation view tabs: each tab accepts presses
// beyond its label, and the selected tab's bar stays above the Trajectory
// view's sticky toolbar where the two meet.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createChatScrollFixture } from './chat-scroll-fixture.ts'
import { launchWebScaffold, seedSession, watchConsole, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = createChatScrollFixture({
markerPrefix: 'VIEW_TABS',
title: 'VIEW_TABS short session',
turns: 4,
})
const SEED_ID = 'conversation-view-tabs-web-e2e'
/** Label and button rectangles of one tab, and which element owns each probe point. */
interface TabProbe {
readonly label: { left: number; top: number; right: number; bottom: number }
readonly button: { left: number; top: number; right: number; bottom: number }
readonly ownsAboveLabel: boolean
readonly ownsBesideLabel: boolean
readonly ownsBarOverhang: boolean
}
/**
* Probe one tab: the run above and beside its label, and the bar's lowest
* half pixel, below the button box.
* @param page - the page under test.
* @param name - the tab's accessible name.
* @returns the tab's rectangles and probe owners.
*/
async function probeTab(page: Page, name: string): Promise<TabProbe> {
return page.getByRole('tab', { name, exact: true }).evaluate((tab) => {
const range = document.createRange()
range.selectNodeContents(tab)
const label = range.getBoundingClientRect()
const button = tab.getBoundingClientRect()
const owns = (x: number, y: number): boolean => document.elementFromPoint(x, y)?.closest('[role="tab"]') === tab
const centerX = (label.left + label.right) / 2
return {
label: { left: label.left, top: label.top, right: label.right, bottom: label.bottom },
button: { left: button.left, top: button.top, right: button.right, bottom: button.bottom },
ownsAboveLabel: owns(centerX, label.top - 8),
ownsBesideLabel: owns(label.left - 8, (label.top + label.bottom) / 2),
ownsBarOverhang: owns(centerX, button.bottom + 0.5),
}
})
}
async function openSeededSession(page: Page): Promise<void> {
const searchButton = page.getByRole('button', { name: 'Search sessions' })
if (await searchButton.getAttribute('aria-expanded') !== 'true') await searchButton.click()
await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(FIXTURE.markers.user(1))
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
const deadline = Date.now() + 60_000
while (await results.count() !== 1) {
if (Date.now() > deadline) throw new Error('seeded session never appeared in the sidebar search results')
await page.waitForTimeout(200)
}
await results.click()
}
describe('web e2e: conversation view tabs', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, FIXTURE.log, SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser, 900)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await openSeededSession(page)
await page.getByText(FIXTURE.markers.assistant(FIXTURE.turns), { exact: false }).last()
.waitFor({ timeout: 30_000 })
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('takes presses above and beside the label', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-view-tabs-target'))
const chat = await probeTab(page, 'Chat')
expect(chat.button.top).toBeLessThan(chat.label.top - 8)
expect(chat.button.left).toBeLessThan(chat.label.left - 8)
expect(chat.ownsAboveLabel).toBe(true)
expect(chat.ownsBesideLabel).toBe(true)
expect(tripwire.pageErrors).toEqual([])
})
it('keeps the selected bar above the Trajectory toolbar', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-conversation-view-tabs-trajectory'))
await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
const toolbar = page.getByRole('toolbar', { name: 'Trajectory toolbar' })
await toolbar.waitFor({ timeout: 30_000 })
const trajectory = await probeTab(page, 'Trajectory')
// The toolbar must start within the bar's overhang, or the probe cannot fail.
expect((await toolbar.boundingBox())!.y).toBeLessThanOrEqual(trajectory.button.bottom + 1)
expect(trajectory.ownsBarOverhang).toBe(true)
expect(tripwire.pageErrors).toEqual([])
})
})
+21 -25
View File
@@ -25,37 +25,33 @@ describe('default navigation entry customization', () => {
try { await browser?.close() } finally { await scaffold?.close() }
})
it('unpins a sidebar panel across reloads while its panel stays reachable from More', async () => {
it('keeps navigation outside More and offers only browsing and creation after reload', async () => {
onTestFailed(() => saveFailureShot(page, 'entry-customization'))
const nav = page.getByRole('navigation', { name: 'Global panels' })
const plugins = nav.getByRole('button', { name: 'Plugins', exact: true })
const tasks = nav.getByRole('button', { name: 'Automation tasks', exact: true })
const more = nav.getByRole('button', { name: 'More', exact: true })
await plugins.waitFor()
await tasks.waitFor()
await more.waitFor()
const menu = page.getByRole('menu')
await more.click()
expect(await menu.getByRole('menuitem', { name: 'Plugins', exact: true }).count()).toBe(0)
await menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Automation tasks', exact: true }).click()
await tasks.waitFor({ state: 'detached' })
await menu.getByRole('menuitem', { name: 'Automation tasks', exact: true }).click()
await tasks.click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
// The selected panel keeps its row until another panel is selected.
await tasks.waitFor()
expect(await tasks.getAttribute('aria-current')).toBe('page')
await plugins.click()
await tasks.waitFor({ state: 'detached' })
expect(await plugins.getAttribute('aria-current')).toBe('page')
await page.reload({ waitUntil: 'load' })
await plugins.waitFor()
await more.waitFor()
expect(await tasks.count()).toBe(0)
await more.click()
await menu.getByRole('menuitem', { name: 'Pin to left sidebar: Automation tasks', exact: true }).click()
await tasks.waitFor()
for (const reload of [false, true]) {
if (reload) {
await page.reload({ waitUntil: 'load' })
await tasks.click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
}
await tasks.waitFor()
await more.click()
const menu = page.getByRole('menu')
await menu.getByRole('menuitem', { name: 'Browse other plugins', exact: true }).waitFor()
expect(await menu.getByRole('menuitem').allTextContents()).toEqual(['Browse other plugins', 'Create your plugin'])
expect(await menu.getByRole('separator').count()).toBe(0)
expect(await tasks.getAttribute('aria-current')).toBe('page')
await menu.getByRole('menuitem', { name: 'Browse other plugins', exact: true }).click()
await menu.waitFor({ state: 'detached' })
expect(await nav.getByRole('button', { name: 'Plugins', exact: true }).getAttribute('aria-current')).toBe('page')
await tasks.click()
await page.getByRole('heading', { name: 'Automation tasks', exact: true }).waitFor()
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
@@ -37,7 +37,7 @@
- text: 上下文窗口
- textbox "上下文窗口 1":
- /placeholder: 1M
- text: 1M
- text: "1048576"
- text: 最大输出 token 数
- textbox "最大输出 token 数 1":
- /placeholder: 256K
@@ -183,13 +183,13 @@ describe('web e2e: current sandbox policy reaches the model before tools', () =>
expect(contexts).toHaveLength(4)
expect(contexts[0]).toContain('Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.')
expect(contexts[0]).toContain('Do not refuse a required modification from this policy alone')
expect(contexts[0]).toContain('Approval policy: ask.')
expect(contexts[0]).toContain('Approval prompts are enabled in this session')
expect(contexts[1]).toContain('Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.')
expect(contexts[1]).toContain('Approval prompts are disabled in this session')
if (sessionWorkspace === undefined) throw new Error('permission-policy scenario observed no session workspace')
expect(contexts[2]).toContain(`Current DSH file policy: workspace-write. Any available operation enforced by the DSH file sandbox may modify files under the session workspace: ${JSON.stringify(canonicalPath(sessionWorkspace))}. Some platform temporary areas may also be writable.`)
expect(contexts[2]).toContain('Approval policy: ask.')
expect(contexts[2]).toContain('Approval prompts are enabled in this session')
expect(contexts[2]).not.toContain('Approval prompts are disabled in this session')
expect(contexts[3]).toContain('Current DSH file policy: read-only.')
+83
View File
@@ -20,6 +20,7 @@ import type { ContextFormed, GenerateOptions, StreamChunk } from '@deepseek-ai/d
import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
import {
ScheduleId,
createAfterScheduleRecord,
createEveryScheduleRecord,
foldScheduleEvents,
type EveryScheduleRecord,
@@ -1259,4 +1260,86 @@ describe.skipIf(MODE === 'record')('web e2e: active Schedule catalog', () => {
await page.clock.setFixedTime(new Date(CATALOG_NOW))
}
}, 60_000)
it('removes every ended task from the page and keeps the active ones', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-schedule-clear-ended'))
const endedSession = SessionId('schedule-manager-ended-clear')
const domain = scaffold.ctx.storageDomain.get('schedule')
if (domain === undefined) throw new Error('Schedule domain was not opened')
const endedIds = [ScheduleId('schedule-ended-first'), ScheduleId('schedule-ended-second')]
const endedTitles = ['Ended release check', 'Ended metrics check'] as const
// An earlier scenario may leave an ended row behind; the confirmation states
// the whole ended set, so the lane starts from an empty one.
await scaffold.ctx.schedule.deleteInactive()
for (const [index, id] of endedIds.entries()) {
const title = endedTitles[index]!
const record = createAfterScheduleRecord(id, title, 60, Date.now(), title)
const receipt = {
scheduledAt: record.scheduledAt,
deliveredAt: new Date(Date.parse(record.scheduledAt) + 1_000).toISOString(),
messageId: MessageId(`${id}-delivery`),
}
await domain.table('tasks').put(record.id, {
sessionId: endedSession, record, status: 'inactive', lastDelivery: receipt,
deliveryHistory: { records: [receipt], earlierRecordsUnavailable: false },
})
}
// The running task is what the cleanup must leave alone. Creating it also
// announces a committed Host change, so the open page reads the rows seeded
// above without waiting for its own first catalog read.
const kept = await scaffold.ctx.schedule.create(endedSession, {
prompt: 'Keep this running task', title: 'Keep this running task', at: '2099-08-25T12:08:00.000Z',
})
try {
await page.getByRole('button', { name: 'Automation tasks', exact: true }).click()
const manager = page.getByTestId('task-manager-page')
// Earlier scenarios leave their own search text and status filter on this
// shared page; this scenario asserts the complete list.
await manager.getByRole('searchbox', { name: 'Search tasks' }).fill('')
await manager.getByRole('button', { name: 'All', exact: true }).click()
const more = manager.getByRole('button', { name: 'More task actions', exact: true })
await more.waitFor({ timeout: 15_000 })
// The confirmation states the Host-side target and removes nothing until it
// is confirmed.
await more.click()
await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click()
const confirmation = page.getByRole('dialog', { name: 'Clear 2 inactive tasks?' })
await confirmation.getByRole('button', { name: 'Cancel', exact: true }).click()
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toHaveLength(2)
await more.click()
await page.getByRole('menuitem', { name: 'Clear all inactive tasks', exact: true }).click()
await confirmation.getByRole('button', { name: 'Clear tasks', exact: true }).click()
await expect.poll(
async () => (await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive'),
{ timeout: 15_000 },
).toEqual([])
const remaining = await scaffold.ctx.schedule.catalog()
expect(remaining.some(task => endedIds.includes(task.id))).toBe(false)
expect(remaining.map(task => task.title)).toContain('Keep this running task')
await page.getByText('Cleared 2 inactive tasks.', { exact: true }).waitFor({ timeout: 15_000 })
// The trigger left with the rows it removed, so the page heading owns the
// focus after the confirmation closes; this confirms the heading fallback
// in a real browser.
await expect.poll(() => page.evaluate(() => document.activeElement?.tagName), { timeout: 15_000 })
.toBe('H1')
// The page follows the acknowledged removal: no ended row or cleanup action
// remains, and the running task is still listed.
await expect.poll(() => manager.getByRole('button', { name: 'More task actions', exact: true }).count(), { timeout: 15_000 })
.toBe(0)
await manager.getByRole('button', { name: 'All', exact: true }).click()
await expect.poll(() => manager.getByRole('list', { name: 'Task catalog' })
.getByRole('button', { name: kept.title, exact: true }).count(), { timeout: 15_000 }).toBe(1)
expect((await scaffold.ctx.schedule.catalog()).filter(task => task.status === 'inactive')).toEqual([])
expect(tripwire.pageErrors).toEqual([])
} finally {
// The shared page and Host outlive a failed assertion: remove every row
// this scenario introduced so the next one starts from the same state. A
// teardown rejection must not mask the assertion that failed first.
await Promise.allSettled(
[kept.id, ...endedIds].map(id => scaffold.ctx.schedule.delete({ sessionId: endedSession, id })),
)
}
}, 60_000)
})
@@ -0,0 +1,108 @@
// Web e2e scenario: a composer statistics pill cut off by the narrow composer
// row shows its full reading in a tooltip on hover. The seed is generated, not recorded: no line is model output.
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { createSystemMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SEED_ID = 'stats-truncated-tooltip-web-e2e'
const USAGE_READING = '5.8M tok · Cache hit 98%'
/**
* Generate one closed single-step turn whose billed usage totals 5.8M tokens at a 98% cache hit.
* @returns session.jsonl text for {@link seedSession}.
*/
function buildSeed(): string {
const lines = [JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
createdAt: 1784974100000, cwd: '{{cwd}}/workspace', isSeeded: false, delegationDepth: 0,
})]
let seq = 0
let time = 1784974100000
const at = (event: Record<string, unknown>): void => {
lines.push(JSON.stringify({ ...event, seq: seq++, time: time++ }))
}
at({ type: 'turn/start', data: { turn: 1 } })
at({ type: 'step/start', data: { turn: 1, step: 1 } })
at({ type: 'system/message', data: { turn: 1, step: 1, message: createSystemMessage('') }, surfaceOp: 'append' })
at({
type: 'user/message',
data: {
id: '00000000-0000-4000-9000-000000000001',
role: 'user',
content: [{ type: 'text', text: 'm1' }],
source: { kind: 'user' },
},
surfaceOp: 'append',
})
at({
type: 'assistant/message',
data: {
stream: [],
turn: 1,
step: 1,
message: {
id: '00000000-0000-4000-8000-000000000001',
role: 'assistant',
content: [{ type: 'text', text: 'r1' }],
source: { kind: 'model', provider: 'snapshot', model: 'snapshot-replier' },
},
usage: { inputTokens: 100_000, outputTokens: 20_000, cacheReadTokens: 5_680_000 },
},
surfaceOp: 'append',
})
at({ type: 'step/end', data: { turn: 1, step: 1 } })
at({ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } })
return `${lines.join('\n')}\n`
}
describe('web e2e: truncated composer statistics', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
if (webSnapshotMode() === 'record') throw new Error('stats-truncated-tooltip is a keyless assembled scenario')
scaffold = await launchWebScaffold({})
await seedSession(scaffold, buildSeed(), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('shows the full reading of a pill the narrow composer cuts off', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-stats-truncated-tooltip'))
await page.locator('[role="treeitem"]').first().click()
await page.locator('[role="treeitem"]').nth(1).click()
const usagePill = page.getByRole('button', { name: USAGE_READING, exact: true })
await usagePill.waitFor({ timeout: 15_000 })
const tooltip = page.getByRole('tooltip')
const label = usagePill.locator(':scope > span')
const cut = () => label.evaluate(element => element.scrollWidth > element.clientWidth)
expect(await cut()).toBe(false)
await page.setViewportSize({ width: 390, height: 900 })
await expect.poll(cut).toBe(true)
await usagePill.hover()
await expect.poll(() => tooltip.textContent()).toBe(USAGE_READING)
await usagePill.click()
await page.getByRole('dialog', { name: 'Token usage' }).waitFor()
expect(await tooltip.count()).toBe(0)
}, 60_000)
it('stayed clean', () => {
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
})
@@ -77,6 +77,7 @@ it('renders durable record images in the Trajectory details panel from the share
// The virtual ledger needs a measurable viewport; jsdom reports zero
// heights, so pin one and neutralize the imperative tail scroll.
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600)
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000)
Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
configurable: true,
value: () => {},
+3 -1
View File
@@ -374,7 +374,9 @@ describe('web e2e: macOS window drag coverage', () => {
{
row: 'conversation header',
what: 'the blank run right of the view tabs, on the tab strip row',
at: () => [tabs.x + tabs.width + 8, tabs.y + tabs.height / 2],
// The last tab's press target overhangs the tablist by 10px and the
// More launcher starts 20px past it; probe the run between them.
at: () => [tabs.x + tabs.width + 15, tabs.y + tabs.height / 2],
drag: true,
},
{
+2
View File
@@ -149,6 +149,7 @@
"tests/preview-boot.e2e.ts",
"tests/seeded-history.e2e.ts",
"tests/stats-paged-history.e2e.ts",
"tests/stats-truncated-tooltip.e2e.ts",
"tests/sidebar-scrollbar.e2e.ts",
"tests/sidebar-title-hover-scroll.e2e.ts",
"tests/rail-search-expand.e2e.ts",
@@ -222,6 +223,7 @@
"tests/chat-long-interactions.e2e.ts",
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/conversation-view-tabs.e2e.ts",
"tests/window-drag-coverage.e2e.ts",
"tests/complex-history.perf.ts",
"tests/pwsh-terminal.e2e.ts",
+7 -17
View File
@@ -1791,25 +1791,15 @@ export interface Config {
## `@deepseek-ai/dsh-llm-deepseek-account`
- `inject`: `llm`
- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ModelOverride`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:15`](../packages/llm/llm-deepseek-account/src/config.ts)
```ts config-catalog
/** Account route configuration; authentication comes exclusively from the account service. */
export interface Config extends Omit<ProtocolConfig, 'models'> {
/** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */
modelsTimeoutMs: Volatile<number>
/**
* Listing age after which the next access refreshes it in the background;
* a failed load retries after the same age (default ten minutes).
*/
modelsMaxAgeMs: Volatile<number>
export interface Config extends Omit<ProtocolConfig, 'models'>, ModelListingConfig {
/** Field overrides by model id, layered over the account listing and the built-in catalog. */
modelOverrides: Volatile<Record<string, ModelOverride>>
}
/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */
export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-llm-deepseek-account -->
@@ -1819,12 +1809,12 @@ export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
## `@deepseek-ai/dsh-llm-deepseek-api-key`
- `inject`: `llm`
- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:10`](../packages/llm/llm-deepseek-api-key/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:18`](../packages/llm/llm-deepseek-api-key/src/config.ts)
```ts config-catalog
/** Messages configuration with a per-request API-key reference. */
export interface Config extends ProtocolConfig {
export interface Config extends ProtocolConfig, ModelListingConfig {
/** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */
apiKeyEnv: Volatile<string>
}
@@ -4371,7 +4361,7 @@ export interface Config {
## `@deepseek-ai/dsh-user-approval`
- `inject`: `sessionProjections`
- `source`: [`packages/interaction/user-approval/src/index.ts:147`](../packages/interaction/user-approval/src/index.ts)
- `source`: [`packages/interaction/user-approval/src/index.ts:152`](../packages/interaction/user-approval/src/index.ts)
```ts config-catalog
/** Plugin config. All optional — `static Config` supplies the defaults. */
+7 -17
View File
@@ -1793,25 +1793,15 @@ export interface Config {
## `@deepseek-ai/dsh-llm-deepseek-account`
- `inject`: `llm`
- `refs`: [`DeepSeekCatalogModel`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:19`](../packages/llm/llm-deepseek-account/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ModelOverride`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-account/src/config.ts:15`](../packages/llm/llm-deepseek-account/src/config.ts)
```ts config-catalog
/** Account route configuration; authentication comes exclusively from the account service. */
export interface Config extends Omit<ProtocolConfig, 'models'> {
/** Deadline of one listing request, which also bounds a first-use wait (default five seconds). */
modelsTimeoutMs: Volatile<number>
/**
* Listing age after which the next access refreshes it in the background;
* a failed load retries after the same age (default ten minutes).
*/
modelsMaxAgeMs: Volatile<number>
export interface Config extends Omit<ProtocolConfig, 'models'>, ModelListingConfig {
/** Field overrides by model id, layered over the account listing and the built-in catalog. */
modelOverrides: Volatile<Record<string, ModelOverride>>
}
/** Field overrides for one model; unset fields and empty lists inherit the underlying entry. */
export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
```
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-llm-deepseek-account -->
@@ -1821,12 +1811,12 @@ export type ModelOverride = Omit<DeepSeekCatalogModel, 'id'>
## `@deepseek-ai/dsh-llm-deepseek-api-key`
- `inject`: `llm`
- `refs`: [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:10`](../packages/llm/llm-deepseek-api-key/src/config.ts)
- `refs`: [`ModelListingConfig`](../packages/llm/llm-deepseek/src/index.ts) · [`ProtocolConfig`](../packages/llm/llm-deepseek/src/index.ts) · `Volatile` (`@deepseek-ai/cordis`)
- `source`: [`packages/llm/llm-deepseek-api-key/src/config.ts:18`](../packages/llm/llm-deepseek-api-key/src/config.ts)
```ts config-catalog
/** Messages configuration with a per-request API-key reference. */
export interface Config extends ProtocolConfig {
export interface Config extends ProtocolConfig, ModelListingConfig {
/** Credential reference resolved per request; defaults to DEEPSEEK_API_KEY. */
apiKeyEnv: Volatile<string>
}
@@ -4373,7 +4363,7 @@ export interface Config {
## `@deepseek-ai/dsh-user-approval`
- `inject`: `sessionProjections`
- `source`: [`packages/interaction/user-approval/src/index.ts:147`](../packages/interaction/user-approval/src/index.ts)
- `source`: [`packages/interaction/user-approval/src/index.ts:152`](../packages/interaction/user-approval/src/index.ts)
```ts config-catalog
/** Plugin config. All optional — `static Config` supplies the defaults. */
+2 -2
View File
@@ -60,7 +60,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:407`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:414`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -105,7 +105,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `internal/service` | - | `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared -->
+2 -2
View File
@@ -62,7 +62,7 @@
| `plugin-manager/install-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:407`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-manager/update-state` | `emit` | [`packages/boot/plugin-manager/src/types.ts:414`](../packages/boot/plugin-manager/src/types.ts) | [`plugin-manager`](../packages/boot/plugin-manager) (`emit`) | `remotes` |
| `plugin-market/changed` | `emit` | [`packages/boot/plugin-market/src/types.ts:168`](../packages/boot/plugin-market/src/types.ts) | [`plugin-market`](../packages/boot/plugin-market) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:478`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `schedule/changed` | `emit` | [`packages/schedule/schedule/src/types.ts:484`](../packages/schedule/schedule/src/types.ts) | [`schedule`](../packages/schedule/schedule) (`emit`) | `remotes` |
| `schedule/resolve-agent` | `waterfall` | [`packages/schedule/schedule/src/index.ts:74`](../packages/schedule/schedule/src/index.ts) | [`schedule`](../packages/schedule/schedule) (`waterfall`) | `server` |
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
| `session/created` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -107,7 +107,7 @@
| `internal/service` | - | `gateway` |
| `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) |
| `internal/update` | - | [`app-boot`](../packages/boot/app-boot) |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
| `slots/changed` | `ui-renderer` (`emit`) | - |
<!-- END GENERATED event-producer-consumer:undeclared -->
+1 -2
View File
@@ -730,7 +730,6 @@ flowchart TD
pkg_llm_deepseek_account --> pkg_launch_environment
pkg_llm_deepseek_account --> pkg_llm
pkg_llm_deepseek_account --> pkg_llm_deepseek
pkg_llm_deepseek_account --> pkg_timeout
pkg_tools --> pkg_agent
pkg_tools --> pkg_llm
pkg_tools --> pkg_scope
@@ -1692,7 +1691,7 @@ flowchart TD
| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+1 -2
View File
@@ -732,7 +732,6 @@ flowchart TD
pkg_llm_deepseek_account --> pkg_launch_environment
pkg_llm_deepseek_account --> pkg_llm
pkg_llm_deepseek_account --> pkg_llm_deepseek
pkg_llm_deepseek_account --> pkg_timeout
pkg_tools --> pkg_agent
pkg_tools --> pkg_llm
pkg_tools --> pkg_scope
@@ -1694,7 +1693,7 @@ flowchart TD
| [`terminal`](../packages/terminal/terminal) | `terminal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand) |
| [`loader-smoke`](../packages/test-support/loader-smoke) | `test-support` | [`agent`](../packages/core/agent), [`http-proxy`](../packages/util/http-proxy), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`timeout`](../packages/util/timeout) |
| [`llm-deepseek-account`](../packages/llm/llm-deepseek-account) | `llm` | [`deepseek-account`](../packages/credentials/deepseek-account), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) |
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/interaction/user-approval) |
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`llm`](../packages/llm/llm) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
+2 -2
View File
@@ -174,8 +174,8 @@
en: e5b55405bd2b69f5
zh: e5b55405bd2b69f5
/session-persistence-event-catalog/events/schedule/schedule-change-log-only:
en: 162584304e006c72
zh: d8a6bacf60777aa9
en: e73c202fb99159b6
zh: b1f43bbd116f83da
/session-persistence-event-catalog/events/session:
en: a1ac8f2660a4f61e
zh: a1ac8f2660a4f61e
+1 -1
View File
@@ -813,7 +813,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s
Types: [ScheduleChange](subsystems/schedule.md)
Source: [`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/schedule/src/types.ts)
Source: [`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts)
### `session/*`
+1 -1
View File
@@ -815,7 +815,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[ScheduleChange](subsystems/schedule.zh.md)
来源:[`packages/schedule/schedule/src/types.ts:365`](../packages/schedule/schedule/src/types.ts)
来源:[`packages/schedule/schedule/src/types.ts:371`](../packages/schedule/schedule/src/types.ts)
### `session/*`
+2 -2
View File
@@ -27,8 +27,8 @@
en: 93798ad69f74ddd5
zh: 634126ad8accc356
/host-wide-schedule/web-catalog:
en: b5e5ac8125e65f33
zh: 9ee064d833a4f838
en: 3377a013bf8cbfb7
zh: c5666dc3f28b087f
/host-wide-schedule/timing-edits:
en: ce5d2095a3ad2e82
zh: 167b3715f8eaffc1
+25 -1
View File
@@ -309,6 +309,14 @@ type ScheduleDeleteResult =
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
```
```ts type-equiv
/** Successful Host-wide removal of every ended task, whether or not any remained. */
interface ScheduleDeleteInactiveResult {
/** Ended task rows this call removed; zero when the Host held none. */
readonly deleted: number
}
```
## Web catalog
The Remote `schedule.catalog()` method returns active and inactive Host tasks with their original Session bindings, ordered by `scheduledAt` ascending and then lexicographically by `id`. It reads only the Schedule domain, without activating Sessions or reading their history. Browser consumers receive this browser-safe type:
@@ -337,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
}
```
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
The Remote `schedule.list({ sessionId })`, model `schedule_list`, and Session-header catalog return only active tasks. A model view's derived timing `state` remains distinct from stored lifecycle `status`. Deletion uses `schedule.delete({ sessionId, id })` with the entry's original binding; a mismatched binding returns not found. Model tools supply the current Agent's Session, whereas the global user interface supplies the selected task's binding. The binding check alone does not establish caller authorization. `schedule.deleteInactive()` removes every ended (inactive) task across Sessions, with the saved delivery records each row carries; on success it answers `{ deleted }` with the count it removed; a normal failed batch rejects with nothing removed; an `unknown-state` failure may have removed rows and quarantines the Host schedule domain, so later schedule operations reject until it is reopened. Any rejection announces `schedule/changed`, and clients then re-read. The Automation tasks page's bulk cleanup uses it. An ended task never arms a timer, so the removal recomputes no target. The payload-free `schedule/changed` event invalidates client lists; reconnecting clients fetch current state again.
The shipped Web composition mounts `ui-schedule` with the Host capability. The [client package](../../packages/client/ui-schedule/README.md) owns the catalog, empty state, and deletion controls. The page separately filters all, active, and inactive tasks, retains inactive details and an original-Session control in the detail tab strip, and requires explicit confirmed deletion. The Rules and Records tabs separate task settings from lazily paged saved receipts. Neither a receipt nor inactive status confirms model execution.
@@ -508,6 +516,22 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor
*/
@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise<ScheduleDeleteResult>
/**
* Delete every ended Host task, leaving active tasks armed.
*
* Ended tasks never schedule another delivery, so the removal changes no pending
* target and no timer is recomputed. Rows are enumerated and removed in one FIFO
* slot, so a delivery ordered behind this call ends its task after the
* enumeration rather than during it. The batch is one domain write: a normal
* failure rejects with every listed row still stored, while a backend
* `unknown-state` failure may have removed rows and quarantines the Host
* domain, so later schedule operations reject until the domain is reopened.
* Every rejection announces `schedule/changed` because it does not establish
* which rows landed, and clients then re-read the catalog.
* @returns Count of removed rows; zero when the Host held no ended task.
*/
@Remote('deleteInactive') async deleteInactive(): Promise<ScheduleDeleteInactiveResult>
/**
* Update the name, instruction, and timing of an active task within the original Session
* binding without activating the Session or changing saved deliveries.
+25 -1
View File
@@ -309,6 +309,14 @@ type ScheduleDeleteResult =
| { readonly id: ScheduleId; readonly deleted: false; readonly code: 'schedule_not_found' }
```
```ts type-equiv
/** Successful Host-wide removal of every ended task, whether or not any remained. */
interface ScheduleDeleteInactiveResult {
/** Ended task rows this call removed; zero when the Host held none. */
readonly deleted: number
}
```
## Web 目录
Remote 方法 `schedule.catalog()` 返回活动和未运行的宿主任务及其原始 Session 绑定,先按 `scheduledAt` 升序排列,再按 `id` 字典序排列。它只读取 Schedule domain,不激活 Session 或读取其历史。浏览器消费方接收以下可安全用于浏览器的类型:
@@ -337,7 +345,7 @@ type ScheduleCatalogEntry = ScheduleRecord & {
}
```
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
Remote 方法 `schedule.list({ sessionId })`、模型 `schedule_list` 和 Session 页头目录仅返回活动任务。模型视图派生的时间 `state` 与存储的生命周期 `status` 仍是不同概念。删除通过 `schedule.delete({ sessionId, id })` 使用条目的原始绑定;绑定不匹配时返回未找到。模型工具传入当前 Agent 的 Session,全局用户界面则传入所选任务的绑定。仅校验绑定并不构成调用者鉴权。`schedule.deleteInactive()` 删除跨会话的全部未运行(已结束)任务,连同每行携带的已保存的发送记录;成功时以它移除的计数答复 `{ deleted }`;普通失败时不删除任何行;后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,之后该领域的操作都会拒绝,直到重新打开。任何拒绝都会先发出 `schedule/changed`,客户端重新读取。自动化任务页面的批量清理使用该方法。未运行任务从不会安排定时器,因此该删除不重算任何目标。无 payload 的 `schedule/changed` 事件通知客户端刷新列表;重新连接后,客户端再次获取当前状态。
随发行版交付的 Web 组合把 `ui-schedule` 与宿主能力一起挂载。[客户端包](../../packages/client/ui-schedule/README.zh.md) 负责目录、空状态与删除控件。页面单独筛选全部、活动和未运行任务,保留未运行任务详情及详情页签条内的原 Session 入口,并要求显式确认删除。“规则”和“任务运行记录”页签将任务设置与按需分页加载的已保存回执分开。回执与未运行状态均不确认模型执行。
@@ -508,6 +516,22 @@ async create(sessionId: SessionId, request: ScheduleCreateRequest, signal?: Abor
*/
@Remote('delete') async delete(request: ScheduleDeleteRequest, signal?: AbortSignal): Promise<ScheduleDeleteResult>
/**
* Delete every ended Host task, leaving active tasks armed.
*
* Ended tasks never schedule another delivery, so the removal changes no pending
* target and no timer is recomputed. Rows are enumerated and removed in one FIFO
* slot, so a delivery ordered behind this call ends its task after the
* enumeration rather than during it. The batch is one domain write: a normal
* failure rejects with every listed row still stored, while a backend
* `unknown-state` failure may have removed rows and quarantines the Host
* domain, so later schedule operations reject until the domain is reopened.
* Every rejection announces `schedule/changed` because it does not establish
* which rows landed, and clients then re-read the catalog.
* @returns Count of removed rows; zero when the Host held no ended task.
*/
@Remote('deleteInactive') async deleteInactive(): Promise<ScheduleDeleteInactiveResult>
/**
* Update the name, instruction, and timing of an active task within the original Session
* binding without activating the Session or changing saved deliveries.
+2 -2
View File
@@ -3,8 +3,8 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/slots.md
/web-client-slots:
en: dd504c406c346f45
zh: 5dadae8b00aafc5b
en: 808a284f5a4280b5
zh: 1650d6c3a94eae6e
/web-client-slots/declaration-and-lifecycle:
en: f2d2c0d3b8f14ec7
zh: 441ed13ff3f81d2a
+1 -1
View File
@@ -10,7 +10,7 @@ This page documents slot ownership, component inputs, extension APIs, and the sh
The plugin catalog declares `plugins.add.actions`, a root-scoped list for `MenuItemButton` rows after installation in the Add plugin menu. Its owner supplies `onDismiss()` to close the menu before starting an action. The slot follows the owning Plugins page declaration lifetime.
The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Each region persists its own unpinned entry ids; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows.
The root-scoped lists `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` supply the rows of the More menus after the panel rows, the View tabs, and the right-sidebar guide cards. A region draws its More button and menu only while its list has registrations, and passes the customizable entries with their pin state, `onOpen`, `onPinnedChange`, and `onDismiss`. Panel and View entries also carry optional `active` state for the current selection, independently of pin state. Each region keeps its own unpinned entry ids in memory; [`ui-entry-customization`](../../packages/client/ui-entry-customization/README.md) registers the shipped rows.
[ui-workspace](../../packages/client/ui-workspace/README.md#understand-the-implementation) documents Session-row extension positions, visibility, and keyboard behavior.
+1 -1
View File
@@ -10,7 +10,7 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
插件目录声明 `plugins.add.actions`,它是 root 作用域的 list,在添加插件菜单的安装项之后提供 `MenuItemButton` 行。owner 提供 `onDismiss()`,用于开始动作前关闭菜单。该 slot 遵循所属插件页面声明的生命周期。
root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。各区域自行持久化已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。
root 作用域的 list `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions` 提供面板行、View 标签和右侧栏引导卡片之后“更多”菜单的菜单行。区域只在其 list 有注册项时画“更多”按钮与菜单,并传入可自定义的入口及其 Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。面板和 View 入口还通过可选的 `active` 状态标识当前选中项,该状态独立于 Pin 状态。各区域在内存中维护已收起的入口 id;[`ui-entry-customization`](../../packages/client/ui-entry-customization/README.zh.md) 注册随产品提供的菜单行。
[ui-workspace](../../packages/client/ui-workspace/README.zh.md#understand-the-implementation) 记录 Session 行的扩展位置、显示条件和键盘行为。
+4 -4
View File
@@ -9,14 +9,14 @@
en: b11e06e7c0e0fd13
zh: 4585f26ff416cb41
/storage/the-backend-contract:
en: 2316787902c6a94e
zh: e3bda06e1c33acac
en: 225e25b9b4b08865
zh: 53bd3554da610607
/storage/declaring-a-domain:
en: 775cce941500f01f
zh: 78acbc67f48574cb
/storage/the-open-domain:
en: 2468b5df66c49b51
zh: 1587f460e9769db2
en: b69b25bc06f1c052
zh: 8aca58718da520c7
/storage/the-domain-facility-ctx-storagedomain:
en: deed74fcd4506ef4
zh: 8e3e6e5d2dc2e989
+2 -2
View File
@@ -44,7 +44,7 @@ interface StorageBackend {
}
```
A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are arbitrary strings that never reach file paths. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium and durable once resolved. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks every clause against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data.
A backend owns one medium (a file-tree root, a database file) and exposes optional operation groups; `kv` is the only shipped group. `KvFacet.open(descriptor)` opens one named unit — `KvUnitDescriptor` carries the name, current format version, optional compatible record versions, table names, and whether a global singleton slot exists — and returns a `KvUnit` with `loadAll`, `putRecord`, `deleteRecord`, `deleteRecords`, `setGlobal`, and `close`. Unit and table names must match `UNIT_NAME_RE` (safe as a file name and as a SQL identifier segment); record keys are opaque strings in the `single` layout and match `[a-zA-Z0-9_-]+` path segments in the `per-record` layout. A unit does not serialize concurrent writes — ordering belongs to the caller — but each single call is atomic on the medium; durability holds for single-record writes and whole-file publishes, while a `per-record` batch deletion relies on the filesystem persisting directory-entry updates. `deleteRecords` applies a whole batch in one call: it resolves once every listed record is absent from the readable state and the backend accepted the batch, and a rejection normally leaves the batch unapplied; a backend that cannot establish or restore the batch's state rejects with `unknown-state`, after which every later operation on that unit rejects with the same error until `close()` releases it (calls after close reject `closed`). A `single`-layout failure after the atomic replacement (a directory-fsync error) can leave the new file on the medium while the caller sees a rejection; the unit keeps its memory consistent and the next publish rewrites the file. A `single` medium stamped with a different version rejects `version-mismatch`; a `per-record` document stamped outside the accepted set reads as absent. A medium that cannot be parsed as the unit rejects `malformed-medium`. [`backend.ts`](../../packages/storage/storage/src/backend.ts) is the normative clause-by-clause contract, and the shared conformance suite in [`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) checks the shared clauses against each backend. The [json backend](../../packages/storage/storage-json/README.md) republishes one whole human-readable file per unit atomically; the [sqlite backend](../../packages/storage/storage-sqlite/README.md) stores one document per row in one database for frequently updated data.
## Declaring a domain
@@ -123,7 +123,7 @@ interface Domain<S extends DomainSpec> {
}
```
Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place.
Reads are synchronous from authoritative in-memory state: `KvTable` exposes `get`/`entries`/`keys`/`size` (snapshot iterators that stay stable while queued writes land), and the global handle's `get()` serves the spec's `initial` until the first `set` materializes the slot on the medium. Every write — `put`, `delete`, `deleteMany`, `update`, `global.set` — queues on one per-domain chain and reaches backend durability first, then mutates memory, then emits `domain/changed`; a rejected backend write leaves memory untouched, so reads never diverge from the medium; a backend that reports `unknown-state` instead quarantines the domain, and its reads and later writes reject until `close()` releases it. `update(key, fn)` is an atomic read-modify-write at its chain slot (a missing key rejects `missing-key`); `delete` of an absent key resolves `false` with no write and no event. `deleteMany(keys)` removes the present keys as one backend batch in one chain slot, resolves the removed count, and emits one `domain/changed` per removed key; a rejected backend write leaves every record in place and emits nothing; an `unknown-state` rejection quarantines the domain. Returned records are the stored objects themselves, not copies — replace via `put`/`update`, never mutate in place.
## The domain facility: `ctx.storageDomain`
+2 -2
View File
@@ -44,7 +44,7 @@ interface StorageBackend {
}
```
一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键是任意字符串,绝不进入文件路径。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的,且 resolve 后即已持久。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查每项条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。
一个后端拥有一个介质(一棵文件树的根目录、一个数据库文件),并提供可选的操作组;`kv` 是唯一已交付的操作组。`KvFacet.open(descriptor)` 打开一个具名 unit——`KvUnitDescriptor` 携带名称、当前格式版本、可选的兼容记录版本、表名清单,以及是否存在全局单例 slot——并返回提供 `loadAll`、`putRecord`、`deleteRecord`、`deleteRecords`、`setGlobal` 和 `close` 的 `KvUnit`。unit 名与表名必须匹配 `UNIT_NAME_RE`(既可安全用作文件名,也可安全用作 SQL 标识符片段);记录键在 `single` 布局中是不透明字符串,在 `per-record` 布局中则是匹配 `[a-zA-Z0-9_-]+` 的路径片段。unit 不对并发写入做串行化——顺序由调用方负责——但每次单独调用在介质上都是原子的;单记录写入与整文件发布在 resolve 后即已持久,`per-record` 批量删除则依赖文件系统对目录项更新的持久化。`deleteRecords` 以一次调用应用整批删除:所有列出的记录都从可读状态消失且后端接受该批后才 resolve,拒绝时通常整批未应用;无法建立或恢复批次状态的批量删除会以 `unknown-state` 拒绝,之后该 unit 的所有操作都以同一错误拒绝,直到 `close()` 释放它(关闭后的调用以 `closed` 拒绝)。`single` 布局在原子替换之后失败(目录 fsync 错误)时,新文件可能已落在介质上而调用方看到拒绝;unit 的内存保持一致,下一次发布会重写该文件。`single` 介质上记录的版本不同时拒绝 `version-mismatch`;`per-record` 文档的版本在接受集合之外时读作不存在。无法按该 unit 解析的介质拒绝 `malformed-medium`。[`backend.ts`](../../packages/storage/storage/src/backend.ts) 是逐条款的规范性约定,[`tests/contract.ts`](../../packages/storage/storage/tests/contract.ts) 中的共享一致性套件会针对每个后端检查共享条款。[json 后端](../../packages/storage/storage-json/README.zh.md)以原子方式为每个 unit 整文件重新发布一份人类可读文件;[sqlite 后端](../../packages/storage/storage-sqlite/README.zh.md)在单个数据库中每行存储一份文档,用于频繁更新的数据。
## 声明领域
@@ -123,7 +123,7 @@ interface Domain<S extends DomainSpec> {
}
```
读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。
读取是同步的,来自权威的内存态:`KvTable` 暴露 `get`/`entries`/`keys`/`size`(快照迭代器,在排队写入落地期间保持稳定),global 句柄的 `get()` 在第一次 `set` 将 slot 物化到介质之前一直返回 spec 的 `initial`。每次写入——`put`、`delete`、`deleteMany`、`update`、`global.set`——都在同一条逐领域写链上排队,先在后端完成持久化,再更新内存,最后发出 `domain/changed`;后端写入被拒时内存原样不动,因此读取绝不会偏离介质;若后端报告 `unknown-state`,领域会被隔离,读取与后续写入都会拒绝,直到 `close()` 释放它。`update(key, fn)` 在其写链 slot 上是一次原子的读-改-写(键缺失时拒绝 `missing-key`);`delete` 一个不存在的键 resolve 为 `false`,不产生写入也不产生事件。`deleteMany(keys)` 在一个写链 slot 内把存在的键作为一次后端批量删除移除,resolve 为移除数量,并为每个被移除的键发一次 `domain/changed`;后端写入被拒时所有记录保持原样,也不发事件;`unknown-state` 拒绝会隔离整个领域。返回的记录就是存储的对象本身,不是副本——请经 `put`/`update` 整体替换,绝不要就地修改。
## 领域 facility:`ctx.storageDomain`
+2 -2
View File
@@ -6,8 +6,8 @@
en: b1631dcf25e805a1
zh: 8ded02e2ae87f00b
/configure-models/configure-deepseek:
en: 2c6b665adc256b4e
zh: 2240e4c1573ebe9c
en: 44852da64a6a39ec
zh: 4c280e428f0fec08
/configure-models/add-a-third-party-provider:
en: f6fb44a85dc5e4dd
zh: 2b86ea4492c32ed7
+2
View File
@@ -12,6 +12,8 @@ Open **Settings → Models**. The DeepSeek card exposes one API-key field; enter
Keys are write-only. The page receives a redacted descriptor after saving, never the literal secret. The key is stored in `$DSH_HOME/.credentials.yaml`, while settings retain only its credential reference.
With a key saved and the default endpoint, DeepSeek's models follow the list the DeepSeek API publishes. The list loads when the models are first needed, refreshes in the background about every ten minutes while they are in use, and falls back to the built-in models when it cannot load. Editing **Models** under **Customized settings** keeps your own list instead and stops following the published one; **Restore defaults** follows it again and reloads it. While the published list is in use, the card shows the built-in models, so a newly published model appears only in the model picker. The [API-key provider reference](../../../packages/llm/llm-deepseek-api-key/README.md) owns the listing, fallback, and gating rules.
## Add a third-party provider
Choose **Add model provider**. The card opens on **Third-party model provider**: pick a provider dsh ships with — the list shows provider ids such as `anthropic`, `openai`, `moonshotai` for Kimi, or `zai` for GLM — enter its API key, and save. The installed catalog supplies the endpoint, protocol, and model list.
+2
View File
@@ -12,6 +12,8 @@
密钥是只写的。保存后,页面只会收到脱敏描述符,永远不会收到明文密钥。密钥存储在 `$DSH_HOME/.credentials.yaml` 中,settings 只保留它的凭据引用。
保存了密钥且使用默认端点时,DeepSeek 的模型跟随 DeepSeek API 发布的列表。第一次需要模型时加载列表,使用期间约每十分钟在后台刷新一次,无法加载时退回内置模型。在**自定义设置**里编辑**模型目录**会改用你自己的列表,不再跟随发布的列表;点**恢复默认模型**会重新跟随并重新加载。使用发布的列表时,卡片上显示的是内置模型,因此新发布的模型只出现在模型选择器中。列表、兜底和门控规则见 [API key 提供方参考](../../../packages/llm/llm-deepseek-api-key/README.zh.md)。
## 添加第三方提供商
选择**添加模型提供商**。卡片默认打开在**第三方模型提供商**:选取 dsh 自带的提供商——列表显示的是提供商 id,例如 `anthropic`、`openai`、Kimi 对应的 `moonshotai`、GLM 对应的 `zai`——输入其 API 密钥并保存。已安装目录会提供端点、协议和模型列表。
+2 -2
View File
@@ -24,8 +24,8 @@
en: 41dcd70e14ebe9cc
zh: ededead572b59037
/deepseek-ai-dsh-client-ui-chat/turn-token-usage:
en: 059eef868161efe4
zh: 52ecc8e682d4c630
en: cfdfe3d99d3f4e09
zh: 9330bad3d3f7a689
/deepseek-ai-dsh-client-ui-chat/completed-turn-footer:
en: 382821ca3ffa60dd
zh: 9840ffa07c13082a
+1 -1
View File
@@ -70,7 +70,7 @@ A completed Turn shows an expandable usage row only when the loaded window inclu
Settings → General → Performance & usage appears after Send behavior while busy and before Session Log upload, and stores `ui-chat.performanceUsage` as `detailed` (default) or `compact`. Compact shows only available output speed and cache-hit percentage beneath the composer, without interactive statistic dialogs or per-Turn usage. Detailed exposes session statistics and per-Turn token usage. Neither mode shows elapsed time in the completed-turn footer. The preference changes presentation only; accounting and Session events remain intact.
The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them.
The composer statistics are two `conversation.composer.dock` list entries: `activity` (order 0, turn and step counts and output speed, with LLM time, tool time, and TTFT) and `usage` (order 1, token total and cache hit). The controls follow the shared [statistics-control radius](../../../docs/ui-radius.md#radius-scale). Another plugin replaces one control by registering the same id and the same `order`; the dock orders rows by `order`, while a lower `priority` picks the replacement: a dynamic plugin receives one automatically, and a statically composed plugin passes it explicitly. A new id adds a control. Each control carries its id in `data-composer-stat`. An open statistics dialog closes on Escape or on a pointerdown or click outside it, so activating another control by pointer or keyboard swaps dialogs instead of stacking them. When an ellipsis cuts off a reading, a tooltip shows the full reading. A dialog control shows it on hover or keyboard focus, but not while its dialog is open; a reading without a dialog shows it on hover, click, or tap. The session statistics dialog heads its rows with the turn and step counts.
Explicit selections update every consumer immediately; accepted Host settings reconcile the live value, so a refused or pending write keeps the selected mode until the Host answers.
+1 -1
View File
@@ -70,7 +70,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,
设置 → 通用设置 → 性能与用量位于繁忙时的发送行为之后、Session Log 上传之前,将 `ui-chat.performanceUsage` 保存为 `detailed`(默认)或 `compact`。简洁模式仅在输入框下方显示可用的输出速度和缓存命中率,不显示统计交互卡片或每轮用量。详细模式提供会话统计和每轮 token 用量。两种模式的已完成轮次页脚均不显示耗时。该偏好仅影响展示,记账和 Session 事件保持完整。
输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。
输入框统计信息由两个 `conversation.composer.dock` 列表条目组成:`activity`(order 0,轮次数、步骤数与输出速度,以及 LLM 耗时、工具耗时与 TTFT)和 `usage`(order 1,token 总量与缓存命中)。这些控件遵循公共的[统计控件圆角规则](../../../docs/ui-radius.zh.md#radius-scale)。其他插件以相同的 id 和相同的 `order` 注册即可替换某个控件;dock 按 `order` 排列各行,更低的 `priority` 决定由替换者渲染:动态插件会自动获得,静态组合的插件需显式传入。以新 id 注册则新增控件。每个控件在 `data-composer-stat` 中携带其 id。按下 Escape,或在已打开的对话框之外发生 pointerdown 或点击时,该对话框会关闭,因此用指针或键盘激活另一个控件会切换对话框,而不会叠加两个。读数被省略号截断时,提示气泡会显示完整读数。带对话框的控件在悬停或键盘聚焦时显示,对话框打开期间不显示;没有对话框的读数在悬停、点击或轻触时显示。会话统计对话框的标题行显示轮次数与步骤数。
明确选择会立即更新所有使用方;Host 接受的设置会校正实时值,因此写入被拒绝或尚未应答时保持所选模式,直到 Host 应答。
@@ -6,7 +6,7 @@
import { memo, useMemo, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { IconDatabaseOutlineRegular, IconGaugeOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { UseProjection } from '@deepseek-ai/dsh-api-session-controller/client'
import type { InjectFace, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: merges the sessionStats key into SessionProjectionMap for useProjection.
@@ -136,21 +136,27 @@ interface PillContent {
stat: StatId
icon: ReactNode
label: ReactNode
/** The full reading as plain text: the accessible name and the tooltip shown while the label is cut off. */
text: string
}
function exactCount(value: number, t: Translate): string {
return t('message.turnUsage.count', { count: formatExactTokens(value, t) })
}
function joined(first: string, second: string | null): ReactNode {
if (second === null) return first
return (
<>
{first}
<span className={css.sep} aria-hidden>·</span>
{second}
</>
)
/** One or two reading segments, visually joined by a separator glyph. */
function reading(first: string, second: string | null): Pick<PillContent, 'label' | 'text'> {
if (second === null) return { label: first, text: first }
return {
label: (
<>
{first}
<span className={css.sep} aria-hidden>·</span>
{second}
</>
),
text: `${first} · ${second}`,
}
}
// Every figure rides the durable sessionStats projection, so paging and
@@ -169,14 +175,20 @@ function decodeSpeed(stats: WindowStats, t: Translate): string {
})
}
/** The full reading above a pill, only while an ellipsis cuts its label; the delay matches the dock's context meter. */
const FULL_READING_TOOLTIP = { side: 'top', portal: true, delayMs: 200, onlyWhenClipped: true } as const
/** A static reading: used when the pill has no dialog rows or the mode is Compact. */
function PlainPill({ stat, icon, label }: PillContent) {
function PlainPill({ stat, icon, label, text }: PillContent) {
return (
<span className={css.anchor} data-composer-stat={stat}>
<span className={css.pill}>
{icon}
<span className={css.label}>{label}</span>
</span>
{/* No dialog carries a static reading, so a tap also pins the full text. */}
<Tooltip label={text} {...FULL_READING_TOOLTIP} openOnClick>
<span className={css.pill}>
{icon}
<span className={css.label}>{label}</span>
</span>
</Tooltip>
</span>
)
}
@@ -186,8 +198,7 @@ function PlainPill({ stat, icon, label }: PillContent) {
* state; useStatDialog closes it on Escape or an outside pointerdown or click,
* so at most one dialog is open across the dock.
*/
function DialogPill({ stat, icon, label, ariaLabel, title, titleValue, children }: PillContent & {
ariaLabel: string
function DialogPill({ stat, icon, label, text, title, titleValue, children }: PillContent & {
title: string
titleValue?: string
children: ReactNode
@@ -195,17 +206,20 @@ function DialogPill({ stat, icon, label, ariaLabel, title, titleValue, children
const { open, setOpen, rootRef, panelRef, pos } = useStatDialog()
return (
<span ref={rootRef} className={css.anchor} data-composer-stat={stat}>
<button
type="button"
className={css.pill}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={ariaLabel}
onClick={() => { setOpen(!open) }}
>
{icon}
<span className={css.label}>{label}</span>
</button>
{/* The open dialog already shows every figure; a tooltip would cover it. */}
<Tooltip label={text} {...FULL_READING_TOOLTIP} disabled={open}>
<button
type="button"
className={css.pill}
aria-haspopup="dialog"
aria-expanded={open}
aria-label={text}
onClick={() => { setOpen(!open) }}
>
{icon}
<span className={css.label}>{label}</span>
</button>
</Tooltip>
{open && createPortal(
<div
ref={panelRef}
@@ -239,15 +253,16 @@ export const ActivityPill = memo(function ActivityPill({ useChat, useProjection,
const stats = useSessionStats(useChat, useProjection)
const speed = stats.decodeMs > 0 ? decodeSpeed(stats, t) : null
const icon = <IconGaugeOutlineRegular />
if (mode === 'compact') return speed === null ? null : <PlainPill stat="activity" icon={icon} label={speed} />
if (mode === 'compact') return speed === null ? null : <PlainPill stat="activity" icon={icon} {...reading(speed, null)} />
if (stats.steps === 0) return null
const counts = t('stats.counts', { turns: stats.turns, steps: stats.steps })
const content: PillContent = { stat: 'activity', icon, label: joined(counts, speed) }
const content: PillContent = { stat: 'activity', icon, ...reading(counts, speed) }
// A window without one timed figure has no dialog rows to show, so the pill
// stays a plain reading instead of a button opening an empty dialog.
if (stats.llmMs <= 0 && stats.toolMs <= 0 && stats.ttftSteps <= 0 && speed === null) return <PlainPill {...content} />
return (
<DialogPill {...content} ariaLabel={speed === null ? counts : `${counts} · ${speed}`} title={t('stats.dialog.title')}>
// The counts head the dialog, so a cut-off reading stays complete once the dialog opens.
<DialogPill {...content} title={t('stats.dialog.title')} titleValue={counts}>
<dl className={dialogCss.details} data-session-stats-details>
{stats.llmMs > 0 && (
<>
@@ -289,7 +304,7 @@ export const UsagePill = memo(function UsagePill({ useProjection, usePerformance
const cacheHitText = cacheHit !== null ? t('stats.cacheHit', { percent: cacheHit }) : null
const icon = <IconDatabaseOutlineRegular />
if (mode === 'compact') {
return cacheHitText === null ? null : <PlainPill stat="usage" icon={icon} label={cacheHitText} />
return cacheHitText === null ? null : <PlainPill stat="usage" icon={icon} {...reading(cacheHitText, null)} />
}
// Same aggregate as the Turn pill's totalTokens: every prompt-side billing bucket plus output.
const total = billedInputTokens(usage) + usage.outputTokens
@@ -298,8 +313,7 @@ export const UsagePill = memo(function UsagePill({ useProjection, usePerformance
<DialogPill
stat="usage"
icon={icon}
label={joined(totalText, cacheHitText)}
ariaLabel={cacheHitText === null ? totalText : `${totalText} · ${cacheHitText}`}
{...reading(totalText, cacheHitText)}
title={t('stats.dialog.usageTitle')}
titleValue={exactCount(total, t)}
>
@@ -268,7 +268,7 @@ describe('composer stats pills', () => {
expect(dialog.getAttribute('aria-label')).toBe('Session statistics')
// Portaled out of the composer dock.
expect(dialog.parentElement).toBe(document.body)
expect(dialog.firstChild?.textContent).toBe('Session statistics')
expect(dialog.firstChild?.textContent).toBe('Session statistics1 turns 1 steps')
const details = dialog.querySelector('[data-session-stats-details]') as HTMLElement
expect(details).toBeTruthy()
expect(details.textContent).toContain('LLM time3.8s')
@@ -375,6 +375,53 @@ describe('composer stats pills', () => {
expect(usageDialog.textContent).toContain('未缓存输入5 tok')
})
describe('full reading of a cut-off pill', () => {
const FULL = '105 tok · Cache hit 90%'
function renderCut(overflow: boolean, mode: 'detailed' | 'compact' = 'detailed') {
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockReturnValue(overflow ? 200 : 80)
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80)
vi.useFakeTimers()
const { source } = makeSource({ nodes: [timedStep()] })
return render(<StatsPills {...props(source)} usePerformanceUsage={selector => selector(mode)} />)
}
const tooltipText = (view: ReturnType<typeof render>) => view.queryByRole('tooltip')?.textContent ?? null
afterEach(() => { vi.useRealTimers() })
it.each([false, true])('shows on hover after the dock delay only while the label is cut off: %s', (overflow) => {
const view = renderCut(overflow)
fireEvent.pointerEnter(view.getAllByRole('button')[1]!)
expect(tooltipText(view)).toBeNull()
act(() => { vi.advanceTimersByTime(200) })
expect(tooltipText(view)).toBe(overflow ? FULL : null)
})
it('shows on keyboard focus of a dialog pill', () => {
const view = renderCut(true)
fireEvent.keyDown(document.body, { key: 'Tab' })
fireEvent.focus(view.getAllByRole('button')[1]!)
expect(tooltipText(view)).toBe(FULL)
})
it('yields to the open dialog, whose head carries the counts', () => {
const view = renderCut(true)
const [timePill] = view.getAllByRole('button') as [HTMLElement]
fireEvent.click(timePill)
fireEvent.pointerEnter(timePill)
act(() => { vi.advanceTimersByTime(200) })
expect(tooltipText(view)).toBeNull()
expect(view.getByRole('dialog').firstChild?.textContent).toBe('Session statistics1 turns 1 steps')
})
it('pins a cut-off static reading on tap', () => {
const view = renderCut(true, 'compact')
const plainPill = view.getByText('Cache hit 90%').parentElement!
fireEvent.pointerDown(plainPill, { pointerType: 'touch' })
fireEvent.click(plainPill)
expect(tooltipText(view)).toBe('Cache hit 90%')
})
})
it('keeps the durable usage pill after the visible step window is empty', () => {
const { source } = makeSource()
const view = render(<StatsPills {...props(source, {
@@ -21,8 +21,8 @@
en: 7871f9fe6bcc4459
zh: 0d51565c600b3958
/deepseek-ai-dsh-client-ui-conversation/shell-and-standard-props:
en: 6ceacacf8770eec9
zh: 4864c23171b99a82
en: e910ac7fec4fd72d
zh: 741a399436dc98d9
/deepseek-ai-dsh-client-ui-conversation/temporary-composer-entries:
en: bb1244310f5c0382
zh: 824b84077432b559
+3 -1
View File
@@ -82,10 +82,12 @@ The context-occupancy button shows a ring and percentage below the input card, a
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.
While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (available Views in registration order with `id`, `label`, and `pinned`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps one root list of unpinned View ids in browser `localStorage` under `dsh.slot.manual-order.conversation.view.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the tab bar omits unpinned Views, and Chat always shows. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View leaves its content selected and keeps its tab, with `aria-selected`, until another View is selected. Ids of Views that are not registered stay stored; stored content other than a string array shows every tab.
While the root-scoped `conversation.session.header.views.actions` list has registrations, a **More conversation views** button with a tooltip follows the View tabs in the tab row. It opens a menu whose rows are that list's registrations. Each receives `entries` (Views outside the header in registration order with `id`, `label`, `pinned`, and `active`; Chat is never listed), `onOpen(id)`, which selects the View in this Session, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The header keeps an in-memory list of unpinned View ids shared by every Session; the tab bar omits unpinned Views and places registered Chat first so it always remains outside More. Plugin reload resets pin choices, and legacy localStorage values are ignored. The tab bar's visibility counts available Views, not pinned ones, so the bar and More stay while every other View is unpinned. Unpinning the active View hides its tab immediately without changing the selected content. More lists only Views outside the header and identifies the current View through `active`. The header reads its available row width, reserves 48px for More and padding, and estimates 120px per tab. Remaining Views keep registration order in More. A single ResizeObserver watches the tab row so window resizing and sidebar expansion update the split; active selection does not change the order. The observer disconnects when the row is removed. If ResizeObserver is unavailable, the initial width calculation still applies without subsequent observation. The More button highlights and names the active View in its tooltip when that View is folded. Individual tabs are not measured. At least one preferred tab remains outside, and long labels truncate with a full-name tooltip. Unregistered View ids remain in the in-memory pin list until plugin reload.
The composer registers the File command action and owns its label, availability, and native file-dialog callback. Menu availability and invocation both consult the mounted composer's current attachment-intake policy. Unmounting or locking the composer disables that action; disposing the plugin removes its registration. The callback binding stays inside the input module.
The composer reads its Session's access answers through the Session-scope `useAccess` Hook: Send disappears while the access snapshot excludes `session/prompt`, Stop while it excludes `session/cancel`, and file intake follows `fileUploads/upload`. A composer without a Session has no answer and keeps its ordinary controls. Send and Stop are withheld from their buttons alone: a keyboard submission or the Esc-Esc sequence still reaches the Host, which refuses it and reports the failure. These UI answers never replace Host authorization.
`SessionInputShell` owns one Lexical editor per Session through its private [DraftEditorRuntime](src/client/input/editor/runtime.ts), while retaining submission, attachment selection, and recovery decisions. [DraftEditor](src/client/input/editor/DraftEditor.tsx) renders the borrowed editor; InputBar retains its Hooks and refs and installs DOM behavior through [view-binding](src/client/input/editor/view-binding.ts). Editor-facing types live in [draft-editor.ts](src/client/contract/draft-editor.ts), with shared input and submission types in [input.ts](src/client/contract/input.ts). Background editor updates retain the draft selection without changing the document selection or reclaiming focus from another control; explicit Composer focus restores its selection. This separation does not support simultaneous editable roots for one Session; [the two-stage isolation proposal](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.md) defines the remaining work.
Drafts retain plain text and file, folder, and Session reference chips across Session switching and reload. Public input methods accept text; internal restoration and Workspace transfer preserve structured references. The input model imports saved content before its first published read; React only mounts that editor and binds the existing Conversation store writer. The original storage key accepts legacy strings and writes semantic snapshots without runtime node IDs. A pending skill catalog does not block editing: slash names remain ordinary text until a matching catalog entry arrives. Arrival rematches the current text without changing chips, selection, or Undo history. Browser-owned attachment File objects are not persisted by this mechanism. The [draft initialization decision](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.md) defines ownership and restoration.
+3 -1
View File
@@ -82,10 +82,12 @@ composer 在挂载、切换 Session 和解除锁定时使用共享的[文字输
关闭代码工作工具时,外壳仅隐藏 Trajectory;其他插件贡献的 View 仍然可用。可用 View 少于两个时隐藏 View 标签栏。在 Trajectory 激活时关闭代码工作工具会返回 Chat;已保存的 View 偏好和 Session 记录保持不变。开启后,Trajectory 恢复可用。View 所有者接收可用列表,使导航操作跟随相同的可见性。
root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可用 View,含 `id`、`label` 与 `pinned`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在浏览器 `localStorage` 的 `dsh.slot.manual-order.conversation.view.v1` 下保存一份 root 级的已收起 View id 列表,格式为 `{ "unpinned": string[] }`,所有 Session 共用;标签栏不显示已收起的 View,Chat 始终显示。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 不会切走其内容,其标签及 `aria-selected` 保留到选中其他 View 为止。未注册 View 的 id 保留在列表中;保存内容不是字符串数组时,所有标签都显示。
root 作用域的 `conversation.session.header.views.actions` list 有注册项时,标签行中的 View 标签之后显示带提示框的**更多会话视图**按钮。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的头部之外的 View,含 `id`、`label`、`pinned` 与 `active`;不含 Chat)、在本 Session 选择 View 的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。头部在内存中保存所有 Session 共用的已收起 View id 列表;标签栏不显示已收起的 View,并将已注册的 Chat 放在首位,确保它始终留在更多菜单外。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。标签栏是否显示按可用 View 计算,不按已固定的 View 计算,因此其他 View 全部收起时,标签栏和“更多”仍然显示。收起当前激活的 View 会立即隐藏其标签,但不会切走其内容。更多菜单仅包含头部之外的 View,通过 `active` 标识当前 View。头部读取当前行可用宽度,为更多按钮与内边距预留 48px,每个标签估算 120px,其余 View 按注册顺序进入更多。单个 ResizeObserver 观察标签行,使窗口缩放和侧栏展开能够更新分组;当前选中项变化不改变顺序。标签行移除时断开观察。如果环境没有 ResizeObserver,仍按初始宽度计算,但不继续观察。当前 View 收起时,更多按钮高亮并在提示框中显示其名称。不逐项测量标签。至少保留一个优先展示的外部标签,长标题截断并通过提示框提供全名。未注册 View 的 id 保留在内存 Pin 列表中,直到插件重新加载。
输入框注册「文件」命令动作,负责其标题、可用性和原生文件选择器回调。菜单可用性与实际调用都读取已挂载输入框当前的附件接收策略。输入框卸载或锁定后该动作不可用,插件 dispose(资源释放)时移除注册。回调绑定留在输入模块内部。
输入框通过会话作用域的 `useAccess` 钩子读取所在 Session 的访问答案:访问快照不含 `session/prompt` 时隐藏发送,不含 `session/cancel` 时隐藏停止,附件接收跟随 `fileUploads/upload`。没有 Session 的输入框尚无答案,保留普通控件。发送与停止都只对按钮生效——键盘提交或 Esc-Esc 序列仍会到达 Host,由 Host 拒绝并上报失败。这些界面判断不替代 Host 授权。
`SessionInputShell` 通过私有 [DraftEditorRuntime](src/client/input/editor/runtime.ts) 为每个 Session 持有一个 Lexical editor,同时保留提交、附件选择和恢复决策。[DraftEditor](src/client/input/editor/DraftEditor.tsx) 呈现借用的 editor;InputBar 保留钩子与 refs,并通过 [view-binding](src/client/input/editor/view-binding.ts) 安装 DOM 行为。编辑器类型位于 [draft-editor.ts](src/client/contract/draft-editor.ts),共享输入和提交类型位于 [input.ts](src/client/contract/input.ts)。编辑器后台更新会保留草稿选区,不修改文档选区或从其他控件夺回焦点;显式聚焦 Composer 时恢复其选区。这一拆分不支持同一 Session 同时挂载多个可编辑 root;[两阶段隔离提案](../../../.agents/notes/proposed/architecture/2026-09-14-composer-model-and-draft-editor.zh.md) 定义剩余工作。
草稿中的普通文字及文件、目录、Session 引用胶囊在切换 Session 和刷新后保留。公开输入方法接受文本;内部恢复和工作区携带保留结构化引用。输入模型在首次对外读取前导入保存内容;React 只挂载同一编辑器并绑定现有 Conversation store 写入回调。原存储 key 兼容旧字符串,新写入保存不含运行时节点 ID 的语义快照。等待 skill 目录不会阻塞编辑:斜杠名称保持普通文字,直到匹配的目录项到达。目录到达只重匹配当前文字,不改变胶囊、选区或 Undo 历史。浏览器持有的附件 File 对象不由这一机制持久化。[草稿初始化决策](../../../.agents/notes/implemented/architecture/2026-09-30-structured-draft-initialization.zh.md)规定所有权与恢复流程。
@@ -158,7 +158,7 @@ export function apply(ctx: Context, config: Config = Config({})): void {
const slots = ctx.slots
// Schemastery's field default is materialized before Cordis calls apply.
const maxConcurrentFileUploads = config.maxConcurrentFileUploads as number
const workspaceNavigation = ctx.get('uiWorkspace') as unknown as WorkspaceNavigation
const workspaceNavigation = ctx.get('uiWorkspace') as WorkspaceNavigation
const uiConversation = new UiConversation(ctx, sessions)
uiConversation.userText.register(sessionReferenceRule)
uiConversation.userText.register(collapseWhitespaceRule)
@@ -448,13 +448,15 @@ export interface ConversationViewActionEntry {
readonly id: string
/** Resolved View label. */
readonly label: string
/** False while the tab is unpinned from the header. */
/** False while the View is manually unpinned. A pinned View still enters More when the header's width budget cannot hold it. */
readonly pinned: boolean
/** Whether this View is selected in this Session, independently of its pin state. */
readonly active?: boolean
}
/** Owner share of the View tab row's More menu rows. */
export interface ConversationViewActionsOwnerProps {
/** Available Views in registration order; Chat is never listed. */
/** Views outside the header in registration order. */
readonly entries: readonly ConversationViewActionEntry[]
/**
* Select a View in this Session without changing its pin state.
@@ -462,9 +464,9 @@ export interface ConversationViewActionsOwnerProps {
*/
readonly onOpen: (id: string) => void
/**
* Show (true) or unpin (false) a View tab in every Session.
* Pin or unpin a View in every Session.
* @param id - Listed View id.
* @param pinned - Whether the tab shows in the header.
* @param pinned - Whether the View is manually pinned; the width budget can still fold it into More.
*/
readonly onPinnedChange: (id: string, pinned: boolean) => void
/** Close the More menu. */
@@ -476,15 +478,15 @@ export interface ConversationSessionHeaderInjected {
/** Package-owned View roster source bound only for the Conversation header. */
readonly hooks: {
readonly conversationViews: ObservableSnapshot<readonly ViewTab[]>
/** Ids of unpinned View tabs, persisted across reloads and shared by every Session. */
/** Ids of unpinned View tabs held in memory and shared by every Session until plugin reload. */
readonly entryPins: ObservableSnapshot<readonly string[]>
/** Whether `conversation.session.header.views.actions` has registrations. */
readonly entryActions: ObservableSnapshot<boolean>
}
/**
* Show or unpin one View tab in every Session; Chat is ignored.
* Pin or unpin one View tab in every Session; Chat is ignored.
* @param id - View id.
* @param pinned - Whether the tab shows in the header.
* @param pinned - Whether the View is manually pinned; the width budget can still fold it into More.
*/
setPinned: (id: string, pinned: boolean) => void
/** Select a Session through the Session Controller. */
@@ -80,6 +80,7 @@ export const zh = {
'hero.chooseWorkspace': '选择工作区',
'session.hierarchy': '会话层级',
'session.moreViews': '更多会话视图',
'session.moreViewsCurrent': '更多会话视图 · {name}',
'todo.title': '任务',
'todo.progress.done': '{done} 已完成',
'todo.progress.active': '{active} 进行中',
@@ -462,6 +463,7 @@ export const en = {
'hero.chooseWorkspace': 'Choose workspace',
'session.hierarchy': 'Session hierarchy',
'session.moreViews': 'More conversation views',
'session.moreViewsCurrent': 'More conversation views · {name}',
'todo.title': 'To-dos',
'todo.progress.done': '{done} completed',
'todo.progress.active': '{active} in progress',
@@ -843,6 +845,7 @@ export const ja = {
'hero.chooseWorkspace': 'ワークスペースを選択',
'session.hierarchy': 'セッション階層',
'session.moreViews': 'その他の会話ビュー',
'session.moreViewsCurrent': 'その他の会話ビュー · {name}',
'todo.title': 'ToDo',
'todo.progress.done': '{done}件完了',
'todo.progress.active': '{active}件進行中',
@@ -198,6 +198,9 @@
z-index: 1;
display: flex;
gap: 16px 20px;
width: 100%;
min-width: 0;
box-sizing: border-box;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 10px;
@@ -212,12 +215,20 @@
min-height: 25px;
}
/* The 16px text line retains 9px below it. The active bar reaches 1px past
the tab box to sit over the header rule. */
/* The 16px text line retains 9px below it. The press target extends 10px
above the text into the strip's top margin and 10px to each side, matching
the inline padding of the Sidebar tab chips (ui-dockkit `.tab`); negative
margins keep the layout box at its 25px row and text width. The active bar
spans the text and reaches 1px past the tab box to sit over the header
rule. */
.tab {
--dsh-tab-hit-inline: 10px;
position: relative;
min-height: 25px;
padding: 0 0 9px;
box-sizing: border-box;
min-height: 35px;
margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0;
padding: 10px var(--dsh-tab-hit-inline) 9px;
border: none;
background: transparent;
font-size: 13px;
@@ -230,9 +241,9 @@
.tab::after {
content: '';
position: absolute;
right: 0;
right: var(--dsh-tab-hit-inline);
bottom: -1px;
left: 0;
left: var(--dsh-tab-hit-inline);
height: 2px;
border-radius: 2px;
background: transparent;
@@ -262,6 +273,10 @@
color: var(--dsw-alias-label-secondary);
}
.viewsMoreActive { color: var(--dsw-alias-state-business-primary); }
.tabLabel { display: block; max-width: 88px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.viewsMore[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -1,6 +1,6 @@
/** Strict per-session header/body content inserted into the resident conversation layout. */
import { useState } from 'react'
import { useLayoutEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import { Button, IconEllipsisOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
@@ -22,17 +22,17 @@ export type ConversationSessionHeaderProps = ConversationSessionHeaderSlotProps
type ViewsMoreMenuProps =
Omit<ConversationViewActionsOwnerProps, 'onDismiss'>
& Pick<ConversationSessionHeaderSlotProps, 'renderSlot'>
& { readonly label: string }
& { readonly label: string; readonly current: boolean }
/** The View tab row's More launcher; its menu rows come from `conversation.session.header.views.actions`. */
function ViewsMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) {
function ViewsMoreMenu({ label, current, entries, onOpen, onPinnedChange, renderSlot }: ViewsMoreMenuProps) {
const [open, setOpen] = useState(false)
const onDismiss = (): void => { setOpen(false) }
return (
<Menu open={open} onClose={onDismiss} portal autoFocus className={css.viewsMoreAnchor} listClassName={css.viewsMoreMenu}
anchor={(
<Tooltip label={label} portal>
<Button size="sm" className={css.viewsMore} aria-label={label} aria-haspopup="menu" aria-expanded={open}
<Button size="sm" className={clsx(css.viewsMore, current && css.viewsMoreActive)} aria-label={label} aria-haspopup="menu" aria-expanded={open}
onClick={() => { setOpen(value => !value) }}>
<IconEllipsisOutlineRegular size={13} />
</Button>
@@ -93,6 +93,31 @@ export function ConversationSessionHeader({
const unpinned = useEntryPins(ids => ids)
const hasEntryActions = useEntryActions(registered => registered)
const showTabs = !hideChrome && tabs.length > 1
const tabRow = useRef<HTMLDivElement>(null)
const [visibleViewLimit, setVisibleViewLimit] = useState(1)
// Observe only the available row width; individual tabs are not measured.
useLayoutEffect(() => {
const row = tabRow.current
if (row === null || !hasEntryActions) return
const updateLimit = (): void => {
setVisibleViewLimit(Math.max(1, Math.floor((row.clientWidth - 48) / 120)))
}
updateLimit()
if (typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(updateLimit)
observer.observe(row)
return () => { observer.disconnect() }
}, [showTabs, hasEntryActions])
const { visibleTabs, moreTabs } = useMemo(() => {
const preferred = [
...tabs.filter(tab => tab.id === FIXED_VIEW_ID),
...tabs.filter(tab => tab.id !== FIXED_VIEW_ID && !unpinned.includes(tab.id)),
]
const visibleTabs = hasEntryActions ? preferred.slice(0, visibleViewLimit) : preferred
const visibleIds = new Set(visibleTabs.map(tab => tab.id))
return { visibleTabs, moreTabs: tabs.filter(tab => !visibleIds.has(tab.id)) }
}, [tabs, unpinned, hasEntryActions, visibleViewLimit])
const currentInMore = moreTabs.some(tab => tab.id === active?.id)
return (
<>
<div className={css.titleRow}>
@@ -169,10 +194,9 @@ export function ConversationSessionHeader({
{showTabs && (
// data-conversation-tabs: marks the tab strip, which the window-chrome
// geometry and the browser coverage lane anchor on.
<div className={css.tabs}>
<div ref={tabRow} className={css.tabs}>
<div className={css.viewTabs} role="tablist" data-conversation-tabs="">
{tabs.filter(viewTab => viewTab.id === FIXED_VIEW_ID || viewTab.id === active?.id
|| !unpinned.includes(viewTab.id)).map(viewTab => (
{visibleTabs.map(viewTab => (
<button
key={viewTab.id}
type="button"
@@ -181,15 +205,18 @@ export function ConversationSessionHeader({
className={clsx(css.tab, viewTab.id === active?.id && css.tabActive)}
onClick={() => { selectView(viewTab.id) }}
>
{viewTab.label}
<Tooltip label={viewTab.label} portal><span className={css.tabLabel}>{viewTab.label}</span></Tooltip>
</button>
))}
</div>
{hasEntryActions && (
<ViewsMoreMenu
label={t('session.moreViews')}
entries={tabs.filter(viewTab => viewTab.id !== FIXED_VIEW_ID)
.map(viewTab => ({ id: viewTab.id, label: viewTab.label, pinned: !unpinned.includes(viewTab.id) }))}
label={currentInMore && active !== undefined ? t('session.moreViewsCurrent', { name: active.label }) : t('session.moreViews')}
current={currentInMore}
entries={moreTabs.map(viewTab => ({
id: viewTab.id, label: viewTab.label,
pinned: !unpinned.includes(viewTab.id), active: viewTab.id === active?.id,
}))}
onOpen={selectView}
onPinnedChange={setPinned}
renderSlot={renderSlot}
@@ -48,6 +48,7 @@ export const InputBar = memo(function InputBar({
retryFileUpload,
toggleCommandMenu, stop, t,
renderSlot, useBusyEnter, useFileUploads, useNotices, useLexicon, useMenuLauncher, useStopShortcut,
useAccess,
useProjection, sessionId, variant, disabled: inert = false, blocked,
workspacePickerOpen = false, onRequestWorkspace,
placeholder, accessory,
@@ -56,6 +57,13 @@ export const InputBar = memo(function InputBar({
const notice = useNotices(s => s)
const busyEnter = useBusyEnter(s => s)
const stopKeys = useStopShortcut(keys => keys)
// A page whose access snapshot excludes prompting, stopping, or uploading to this Session is offered
// no Send, no Stop, or no file intake rather than a disabled control. `useAccess` is undefined only
// without a Session, where the bar is an inert placeholder with no keyboard, stop, or file intake.
const promptDenied = useAccess('session/prompt') === false
const cancelDenied = useAccess('session/cancel') === false
const uploadDenied = useAccess('fileUploads/upload') === false
const stopAction = cancelDenied ? undefined : stop
void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms
const commandMenuOpen = useMenuLauncher(source => source === 'command')
const [activity, setActivity] = useState(false)
@@ -214,7 +222,8 @@ export const InputBar = memo(function InputBar({
// The host enforces the same image limits at submit for callers that bypass
// this composer.
const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet<File>): void => {
if (locked || machineBusy || (subagent !== null && !continuable) || addFiles === undefined || files.length === 0) return
if (uploadDenied || locked || machineBusy || (subagent !== null && !continuable)
|| addFiles === undefined || files.length === 0) return
const rejected = ((): string | null => {
// Child prompts support images, but not generic file receipts or the
// Desktop path-reference fallback. Refuse mixed batches before intake.
@@ -240,9 +249,9 @@ export const InputBar = memo(function InputBar({
return addFiles(files, directories)
})()
if (rejected !== null) showToast(rejected)
}, [locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t])
}, [uploadDenied, locked, machineBusy, subagent, continuable, addFiles, attachments, imageLimits, showToast, t])
const canAcceptDrop = (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined
const canAcceptDrop = !uploadDenied && (subagent === null || continuable) && !locked && !machineBusy && addFiles !== undefined
const fileInputRef = useRef<HTMLInputElement | null>(null)
const onPickFiles = (e: ChangeEvent<HTMLInputElement>): void => {
@@ -331,7 +340,7 @@ export const InputBar = memo(function InputBar({
// exposes Stop independently.
const primaryStops = running && subagent === null && (empty || blocked !== undefined)
// Disabled native buttons may omit mouseleave; their tooltip must close from state.
const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending
const primaryDisabled = primaryStops ? stopAction === undefined : empty || disabled || machineBusy || uploadsPending
const interruptible = running && continuable
const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable)
const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/')
@@ -342,7 +351,7 @@ export const InputBar = memo(function InputBar({
: t('input.send')
const onPrimary = (): void => {
if (primaryStops) {
stop?.()
stopAction?.()
return
}
if (keyboard === undefined) return // absent machine: the button is disabled
@@ -467,7 +476,7 @@ export const InputBar = memo(function InputBar({
ref={fileInputRef}
type="file"
multiple
disabled={subagent !== null}
disabled={!canAcceptDrop || subagent !== null}
hidden
onChange={onPickFiles}
/>
@@ -489,15 +498,15 @@ export const InputBar = memo(function InputBar({
{input === undefined || sessionId === undefined ? null : <div className={activity ? css.activityExpanded : css.activity}>
{renderSlot('conversation.input.activity', { locked, onActiveChange: setActivity })}
</div>}
{interruptible && (
<Tooltip label={t('input.stop')} shortcutKeys={stopKeys} side="top" delayMs={500} disabled={stop === undefined}>
{interruptible && !cancelDenied && (
<Tooltip label={t('input.stop')} shortcutKeys={stopKeys} side="top" delayMs={500} disabled={stopAction === undefined}>
<button
type="button"
className={css.primary}
aria-label={t('input.stop')}
disabled={stop === undefined}
disabled={stopAction === undefined}
onMouseDown={keepFocus}
onClick={stop}
onClick={stopAction}
>
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
@@ -505,26 +514,28 @@ export const InputBar = memo(function InputBar({
</button>
</Tooltip>
)}
<Tooltip label={primaryStops ? t('input.stop') : primaryLabel} shortcutKeys={primaryStops ? stopKeys : undefined} side="top" delayMs={500} disabled={primaryDisabled}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={primaryDisabled}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{primaryStops ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
)}
</button>
</Tooltip>
{(primaryStops ? cancelDenied : promptDenied) ? null : (
<Tooltip label={primaryStops ? t('input.stop') : primaryLabel} shortcutKeys={primaryStops ? stopKeys : undefined} side="top" delayMs={500} disabled={primaryDisabled}>
<button
type="button"
className={css.primary}
aria-label={primaryLabel}
disabled={primaryDisabled}
onMouseDown={keepFocus}
onClick={onPrimary}
>
{primaryStops ? (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<rect x="3" y="3" width="10" height="10" rx="3" fill="currentColor" />
</svg>
) : (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden>
<path d="M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z" fill="currentColor" />
</svg>
)}
</button>
</Tooltip>
)}
</div>
</div>
</div>
@@ -1,11 +1,9 @@
/** Persisted pin choices for the Conversation View tabs. */
/** In-memory pin choices for the Conversation View tabs. */
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
/** View whose tab always shows and never enters the customization list. */
export const FIXED_VIEW_ID = 'chat'
/** Root-level key shared with the slot layer's future per-list preferences; Sessions share one list. */
const STORAGE_KEY = 'dsh.slot.manual-order.conversation.view.v1'
const NO_VIEWS: readonly string[] = []
/** Unpinned View ids and their only mutation. */
@@ -13,30 +11,24 @@ export interface ViewPins {
/** Unpinned View ids; ids of Views that are not registered stay listed. */
readonly unpinned: ObservableSnapshot<readonly string[]>
/**
* Show or hide one View tab; the fixed View is ignored.
* Pin or unpin one View tab; the fixed View is ignored. A pinned tab still
* enters More when the header's width budget cannot hold it.
* @param id - View id.
* @param pinned - Whether the tab shows in the Conversation header.
* @param pinned - Whether the tab is manually pinned.
*/
readonly setPinned: (id: string, pinned: boolean) => void
}
/** Stored content is unchecked; anything but a string array leaves every View pinned. */
function unpinnedViews(value: unknown): readonly string[] {
if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_VIEWS
const { unpinned } = value
return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_VIEWS
}
/**
* Create the persisted View pin list; the snapshot keeps its reference until the list changes.
* @returns Validated unpinned View ids and the pin mutation.
* Create the in-memory View pin list; the snapshot keeps its reference until the list changes.
* @returns Unpinned View ids and the pin mutation.
*/
export function createViewPins(): ViewPins {
const store = createSnapshotStore<unknown>({ unpinned: NO_VIEWS }, { persist: { name: STORAGE_KEY } })
const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_VIEWS })
return {
unpinned: { getSnapshot: () => unpinnedViews(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) },
setPinned: (id, pinned) => {
const current = unpinnedViews(store.getSnapshot())
const current = store.getSnapshot().unpinned
if (id === FIXED_VIEW_ID || pinned !== current.includes(id)) return
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
},
@@ -44,7 +44,7 @@ function entry(
}
describe('target-neutral Conversation apply wiring', () => {
it('hands every Session header the same persisted View pins', async () => {
it('hands every Session header the same in-memory View pins without restoring saved choices', async () => {
localStorage.setItem('dsh.slot.manual-order.conversation.view.v1', JSON.stringify({ unpinned: ['trajectory'] }))
try {
const b = await bench()
@@ -56,10 +56,10 @@ describe('target-neutral Conversation apply wiring', () => {
const inject = header.inject as (sessionId: SessionId) => PinHooks
const injected = inject(SID)
const first = injected.hooks.entryPins
expect(first.getSnapshot()).toEqual(['trajectory'])
expect(first.getSnapshot()).toEqual([])
expect(inject('session-2' as SessionId).hooks.entryPins).toBe(first)
injected.setPinned('timeline', false)
expect(first.getSnapshot()).toEqual(['trajectory', 'timeline'])
expect(first.getSnapshot()).toEqual(['timeline'])
const actions = injected.hooks.entryActions
const changed = vi.fn()
actions.subscribe(changed)
@@ -8,8 +8,10 @@ describe('conversation header geometry', () => {
it('keeps the single-line header at the shared 76px height', () => {
expect(css.match(/\.header\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 76px')
expect(css.match(/\.viewTabs\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px')
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 25px')
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 0 0 9px')
// The tab's 35px press target sits 10px above its 25px row through a negative top margin.
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('min-height: 35px')
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('margin: -10px calc(-1 * var(--dsh-tab-hit-inline)) 0')
expect(css.match(/\.tab\s*\{([^}]*)\}/)?.[1]).toContain('padding: 10px var(--dsh-tab-hit-inline) 9px')
})
it('uses 32px between view labels without changing the action gap', () => {
@@ -106,6 +106,8 @@ interface BenchOptions {
attachments?: readonly ComposerAttachment[]
/** Upload states served for file-kind drafts (absent = every file is ready). */
fileUploads?: DraftFileUploads
/** Endpoints the Session's access snapshot excludes (absent = every endpoint is allowed). */
deniedEndpoints?: readonly string[]
addFiles?: (files: readonly File[], directories?: ReadonlySet<File>) => string | null
commandMenuOpen?: boolean
busyEnter?: 'queue' | 'steer'
@@ -193,6 +195,7 @@ function bench(over?: BenchOptions) {
items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null,
})),
...allowAllAccess,
useAccess: endpoint => !(over?.deniedEndpoints ?? []).includes(endpoint),
useProjection: ((key: string, selector?: (v: unknown) => unknown) =>
(selector ?? (v => v))(key === 'plan'
? over?.plan
@@ -708,6 +711,23 @@ describe('image draft rail', () => {
}
})
it('keeps text editable but refuses attachment-slot, picker and paste intake without permission', () => {
const added = vi.fn(() => null)
const result = bench({ deniedEndpoints: ['fileUploads/upload'], addFiles: added, draft: 'text stays editable' })
const attachment = attachmentOwner(result.slotCalls)
expect(attachment.canAcceptDrop).toBe(false)
const file = new File(['note'], 'note.txt')
attachment.onAddFiles([file])
const picker = result.view.container.querySelector<HTMLInputElement>('input[type=file]')!
expect(picker.disabled).toBe(true)
fireEvent.change(picker, { target: { files: [file] } })
fireEvent.paste(result.textarea, { clipboardData: {
items: [{ kind: 'file', type: 'text/plain', getAsFile: () => file }], getData: () => '',
} })
expect(added).not.toHaveBeenCalled()
expect(result.textarea.getAttribute('contenteditable')).toBe('true')
})
it('announces a rejected attachment-slot intake through the same toast', () => {
const addFiles = vi.fn(() => '图片读取服务不可用')
const result = bench({ addFiles })
@@ -1197,6 +1217,41 @@ describe('running and lock semantics', () => {
expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer', expect.any(AbortSignal))
})
it('a running composer whose access excludes prompting and stopping offers neither Stop nor Send', () => {
const { view, stop } = bench({
running: true,
// The bar is handed a live Stop handler all the same, so what withholds the action is the access
// answer and not a missing face.
deniedEndpoints: ['session/prompt', 'session/cancel'],
blocked: { reason: '只读查看:本页由 owner 分享,无法发送消息' },
placeholder: '只读查看:本页由 owner 分享,无法发送消息',
})
expect(view.container.querySelector('button[aria-label="停止生成"]')).toBeNull()
expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull()
expect(stop).not.toHaveBeenCalled()
})
it('a running composer whose access excludes only prompting keeps Stop but offers no busy Send', () => {
const empty = bench({ running: true, deniedEndpoints: ['session/prompt'] })
expect(empty.view.getByLabelText('停止生成')).toBeTruthy()
cleanup()
// A draft would make the primary action the busy Send, which the snapshot excludes.
const drafted = bench({ running: true, deniedEndpoints: ['session/prompt'], draft: '排到下一轮' })
expect(drafted.view.container.querySelector('button[aria-label="排队发送"]')).toBeNull()
})
it('an idle composer whose access excludes prompting offers no Send', () => {
const { view } = bench({ deniedEndpoints: ['session/prompt'], draft: 'hello' })
expect(view.container.querySelector('button[aria-label="发送消息"]')).toBeNull()
})
it('a composer whose access has no answer yet keeps its ordinary Send', () => {
const { view, props } = bench({ draft: 'hello' })
// Without a bound Session the Session-scope Hook answers undefined, which denies nothing.
view.rerender(<InputBar {...props} useAccess={() => undefined} />)
expect(view.getByLabelText('发送消息')).toBeTruthy()
})
it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => {
const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' })
fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true })
@@ -1959,6 +2014,21 @@ describe('command launcher chrome and control seats', () => {
expect(launcher.getAttribute('aria-expanded')).toBe('true')
})
it('follows the access answers over a running Session, and restores the actions when they return', () => {
const { view, props } = bench({ running: true })
// While the page may stop the running Session, the primary action is its Stop.
expect(view.getByLabelText('停止生成')).toBeTruthy()
// The access snapshot now excludes the Session's writes: the bar offers neither Stop nor Send.
view.rerender(<InputBar {...props} useAccess={() => false} />)
expect(view.queryByLabelText('停止生成')).toBeNull()
expect(view.queryByLabelText('发送消息')).toBeNull()
// The answers return: the bar offers the action the page holds again.
view.rerender(<InputBar {...props} />)
expect(view.getByLabelText('停止生成')).toBeTruthy()
})
it('opening the command menu from the button puts the keyboard in the editor first', () => {
const toggleCommandMenu = vi.fn()
const { view, textarea } = bench({ toggleCommandMenu })
@@ -85,6 +85,7 @@ function fireResize(el: Element): void {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
vi.unstubAllGlobals()
resizeObservers.length = 0
})
@@ -144,6 +145,8 @@ function mount(
unpinnedViews?: readonly string[]
/** Whether the View actions list has registrations. */
entryActions?: boolean
/** Isolate header capability checks from the composer's required layout observers. */
headerOnly?: boolean
} = {},
) {
const sessionId = 'sessionId' in options ? options.sessionId : SID
@@ -340,6 +343,7 @@ function mount(
const renderFactorySlot = ((_name: string, input: ConversationContentInputProps, factoryOptions?: {
slots?: Record<string, (props: never) => ReactNode>
}) => {
if (options.headerOnly) return null
const common: ConversationViewsProps = {
sessionId: SID,
SessionProvider,
@@ -681,7 +685,30 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.getByRole('tablist').hasAttribute('data-conversation-tabs')).toBe(true)
})
it('keeps the selected View tab while it is unpinned and hides it once another View is selected', () => {
it('keeps Chat outside at the narrowest width even when it registers after other Views', () => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(180)
const viewTabs = ['trajectory', 'timeline', 'chat', 'notes'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat'])
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['trajectory', 'timeline', 'notes'])
act(() => { b.actionsOwner().onOpen('trajectory') })
expect(b.view.getByRole('tab', { name: 'chat' })).toBeTruthy()
expect(b.view.queryByRole('tab', { name: 'trajectory' })).toBeNull()
expect(b.actionsOwner().entries.find(entry => entry.id === 'trajectory')?.active).toBe(true)
})
it('calculates the initial tab budget when ResizeObserver is unavailable', () => {
vi.stubGlobal('ResizeObserver', undefined)
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(300)
const viewTabs = ['chat', 'trajectory', 'timeline'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true, headerOnly: true })
expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)).toEqual(['chat', 'trajectory'])
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline'])
})
it('keeps an unpinned View selected without exposing its tab', () => {
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
@@ -691,7 +718,7 @@ describe('ConversationRoot resident composer', () => {
act(() => { b.store.actions.setView('trajectory') })
const tabs = (): HTMLElement[] => within(b.view.getByRole('tablist')).getAllByRole('tab')
expect(tabs().map(tab => [tab.textContent, tab.getAttribute('aria-selected')])).toEqual([
['Chat', 'false'], ['Trajectory', 'true'], ['Timeline', 'false'],
['Chat', 'false'], ['Timeline', 'false'],
])
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
act(() => { b.store.actions.setView('chat') })
@@ -708,6 +735,75 @@ describe('ConversationRoot resident composer', () => {
expect(b.view.queryByRole('menu')).toBeNull()
})
it('reuses the width budget on View roster and selection changes without a window resize listener', () => {
let width = 480
const readWidth = vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width)
const viewTabs: ViewTab[] = [
{ id: 'chat', label: 'Chat' },
{ id: 'trajectory', label: 'Trajectory' },
{ id: 'timeline', label: 'Timeline' },
{ id: 'inspector', label: 'Inspector' },
]
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)
expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline'])
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['inspector'])
act(() => { b.actionsOwner().onOpen('inspector') })
expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline'])
expect(b.actionsOwner().entries.find(entry => entry.id === 'inspector')?.active).toBe(true)
expect(b.view.getByRole('button', { name: '更多会话视图 · Inspector' })).toBeTruthy()
const reads = readWidth.mock.calls.length
width = 1000
fireEvent(window, new Event('resize'))
act(() => { b.store.actions.setView('timeline') })
expect(readWidth).toHaveBeenCalledTimes(reads)
expect(external()).toEqual(['Chat', 'Trajectory', 'Timeline'])
viewTabs.splice(1, 1)
act(() => { b.store.actions.setView('chat') })
expect(external()).toEqual(['Chat', 'Timeline', 'Inspector'])
expect(readWidth).toHaveBeenCalledTimes(reads)
expect(b.actionsOwner().entries).toEqual([])
expect(b.setPinned).not.toHaveBeenCalled()
})
it('folds and restores Views on container resize without changing selection, and disconnects on unmount', () => {
let width = 1000
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => width)
const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
const row = b.view.getByRole('tablist').parentElement!
const external = () => within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent)
expect(external()).toEqual(viewTabs.map(tab => tab.label))
act(() => { b.store.actions.setView('notes') })
width = 300
act(() => { fireResize(row) })
expect(external()).toEqual(['chat', 'trajectory'])
fireEvent.click(b.view.getByRole('button', { name: '更多会话视图 · notes' }))
expect(b.actionsOwner().entries.map(entry => entry.id)).toEqual(['timeline', 'inspector', 'notes'])
expect(b.actionsOwner().entries.find(entry => entry.id === 'notes')?.active).toBe(true)
width = 1000
act(() => { fireResize(row) })
expect(external()).toEqual(viewTabs.map(tab => tab.label))
expect(b.view.getByRole('tab', { name: 'notes' }).getAttribute('aria-selected')).toBe('true')
expect(b.actionsOwner().entries).toEqual([])
expect(b.setPinned).not.toHaveBeenCalled()
const observers = resizeObservers.filter(observer => observer.targets.includes(row))
expect(observers).toHaveLength(1)
b.view.unmount()
expect(observers[0]!.targets).toEqual([])
})
it('keeps all five Views outside when the sampled width budget is sufficient', () => {
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(1000)
const viewTabs = ['chat', 'trajectory', 'timeline', 'inspector', 'notes'].map(id => ({ id, label: id }))
const b = mount(sessionSnapshotOf(), undefined, undefined, { viewTabs, entryActions: true })
expect(within(b.view.getByRole('tablist')).getAllByRole('tab').map(tab => tab.textContent))
.toEqual(viewTabs.map(tab => tab.label))
fireEvent.click(b.view.getByRole('button', { name: zh['session.moreViews'] }))
expect(b.actionsOwner().entries).toEqual([])
})
it('omits More while the View actions list has no registrations', () => {
const b = mount(sessionSnapshotOf())
expect(b.view.getByRole('tablist')).toBeTruthy()
@@ -726,11 +822,13 @@ describe('ConversationRoot resident composer', () => {
fireEvent.click(more)
expect(more.getAttribute('aria-expanded')).toBe('true')
expect(within(b.view.getByRole('menu')).getByRole('menuitem', { name: 'Header view action' })).toBeTruthy()
expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false }])
expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: false }])
b.actionsOwner().onPinnedChange('trajectory', true)
expect(b.setPinned).toHaveBeenCalledExactlyOnceWith('trajectory', true)
act(() => { b.actionsOwner().onOpen('trajectory') })
expect(b.view.getByTestId('view-trajectory')).toBeTruthy()
expect(b.view.queryByRole('tab', { name: 'Trajectory' })).toBeNull()
expect(b.actionsOwner().entries).toEqual([{ id: 'trajectory', label: 'Trajectory', pinned: false, active: true }])
fireEvent.click(b.view.getByRole('menuitem', { name: 'Header view action' }))
expect(b.view.queryByRole('menu')).toBeNull()
expect(more.getAttribute('aria-expanded')).toBe('false')
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** Conversation View pins persist one root list for every Session and ignore Chat. */
/** Conversation View pins share one in-memory root list for every Session and ignore Chat. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createViewPins } from '../src/client/view-pins.ts'
@@ -7,15 +7,15 @@ const KEY = 'dsh.slot.manual-order.conversation.view.v1'
afterEach(() => { localStorage.clear() })
describe('Conversation View pins', () => {
it('persists unpinned View ids and restores them in a new instance', () => {
it('keeps unpinned View ids in memory without restoring them in a new instance', () => {
const pins = createViewPins()
const initial = pins.unpinned.getSnapshot()
expect(initial).toEqual([])
expect(pins.unpinned.getSnapshot()).toBe(initial)
pins.setPinned('trajectory', false)
pins.setPinned('timeline', false)
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['trajectory', 'timeline'] })
expect(createViewPins().unpinned.getSnapshot()).toEqual(['trajectory', 'timeline'])
expect(localStorage.getItem(KEY)).toBeNull()
expect(createViewPins().unpinned.getSnapshot()).toEqual([])
pins.setPinned('trajectory', true)
expect(pins.unpinned.getSnapshot()).toEqual(['timeline'])
})
@@ -34,20 +34,14 @@ describe('Conversation View pins', () => {
})
it('keeps ids of Views that are not registered', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-view'] }))
const pins = createViewPins()
pins.setPinned('removed-view', false)
pins.setPinned('timeline', false)
expect(pins.unpinned.getSnapshot()).toEqual(['removed-view', 'timeline'])
})
it.each([
['a non-array list', { unpinned: { timeline: true } }],
['non-string ids', { unpinned: [null] }],
['a missing list', { pinned: [] }],
['null', null],
['an array root', ['timeline']],
])('reads %s as no unpinned Views with a stable snapshot', (_name, stored) => {
localStorage.setItem(KEY, JSON.stringify(stored))
it('ignores a legacy stored list instead of restoring it', () => {
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['trajectory'] }))
const pins = createViewPins()
const snapshot = pins.unpinned.getSnapshot()
expect(snapshot).toEqual([])
@@ -3,23 +3,23 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-entry-customization/README.md
/:
en: 200adf0dd8c9757c
zh: 3d10d49a71289702
en: 57416280d387ea2b
zh: f9b97e610116bf59
/deepseek-ai-dsh-client-ui-entry-customization:
en: ab35194f4a9dafce
zh: 7e9e57fde518c3d7
/deepseek-ai-dsh-client-ui-entry-customization/summary:
en: d53e7d71da9fd4d6
zh: a32b635ec1e572f3
en: 589199344fe3dd70
zh: e5e3003c5c4d9479
/deepseek-ai-dsh-client-ui-entry-customization/table-of-contents:
en: 76368b6b1da79c80
zh: 52384aee991ac542
/deepseek-ai-dsh-client-ui-entry-customization/use-this-package:
en: e642513bcfd42d68
zh: 38b9109ec017900d
en: 795f14554e3ff1b0
zh: ed6d8702c9cfa4e7
/deepseek-ai-dsh-client-ui-entry-customization/understand-the-implementation:
en: 2f1a873bebc098c8
zh: c2ec33e50ff2ccc0
en: 65738fbe4d77e47a
zh: 201acc369942cab4
/deepseek-ai-dsh-client-ui-entry-customization/model-experience:
en: b891f311c09d536f
zh: 68c0f41fbfed0d73
@@ -27,8 +27,8 @@
en: dc50ebf94183efa2
zh: 617803c558367430
/deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work:
en: 2e582c76ffe9d663
zh: 7e5e49ad3d6f0f83
en: 5b4914a3ef05fa9c
zh: 9cbe8a17a88ee4d6
/deepseek-ai-dsh-client-ui-entry-customization/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -1,5 +1,5 @@
---
description: "Fill the More menus of three navigation regions with pin controls, plugin browsing, and a Creator draft."
description: "Offer plugin browsing and a Creator draft in three navigation regions."
kind: "package-reference"
---
@@ -11,7 +11,7 @@ Desktop product events use the optional [product analytics service](../product-a
## Summary
Fill the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide with each region's customizable entries, pin buttons, plugin browsing, and a reviewable Creator draft. The Web app loads it by default. Each region draws its own More button and stores its own pin choices; this package stores nothing.
Browse plugins and prepare a reviewable Creator draft from the More menus of the left sidebar, Conversation View tabs, and right-sidebar guide. The Web app loads this package by default. Each region draws its own More button; this package stores nothing.
## Table of Contents
@@ -28,13 +28,13 @@ Fill the More menus of the left sidebar, Conversation View tabs, and right-sideb
The `@deepseek-ai/dsh-web-app` bundle loads this package by default; it needs no Plugins switch or profile selection. A region shows its More button only while its actions slot has a registration, so unloading this package removes all three buttons.
Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Each menu lists the region's customizable entries in registration order; Plugins, Chat, and Workspace files always show and are not listed. Selecting an entry's name closes the menu and opens the entry without changing its pin state. The separate pin button pins or unpins the region's launcher and keeps the menu open; a filled pin marks a pinned entry. Unpinned entries stay reachable through the menu, and pin choices persist across reloads. Long entry names wrap, except in the More tools menu, where they truncate to one line.
Open **More** beneath the left navigation entries, **More conversation views** after the Conversation View tabs, or **More tools** after the right-sidebar guide cards. Left-sidebar and guide menus offer only **Browse other plugins** (when available) and **Create your plugin**. The Conversation menu also lists folded Views before those actions and marks the active View with a check on the right. None of these menus provide pin controls or sorting.
**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Both actions remain available when a region has no customizable entries; an empty list adds no separator.
**Browse other plugins** opens the Plugins main list without opening an installation dialog. **Create your plugin** opens a new or reusable blank Session with the Creator preset and adds a short localized request for a plugin in that region to its draft: an empty draft takes the request as its text, and an existing draft keeps its text, references, and attachments with the request appended as a new paragraph. It never sends a message. Review and send the draft yourself. Creation remains available regardless of the region’s entries; a separator appears only between folded Views and the actions, before browsing when available or before creation otherwise.
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. Opening an entry and changing a pin report nothing, and the menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
On Desktop, **Browse other plugins** and **Create your plugin** report `more_menu_item_click` before they act, with `action=browse_plugins` or `action=create_plugin` and the menu's `region`: `sidebar` for **More**, `conversation` for **More conversation views**, and `rightbar` for **More tools**. The menus report no exposure events. Without the analytics service, both actions behave the same and report nothing.
The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it.
The browse-plugins row appears only while the `pluginNavigation` service is present; the plugin starts without it. Its absence removes only browsing, not More buttons, folded Views, or creation.
-----
@@ -44,7 +44,7 @@ The browse-plugins row appears only while the `pluginNavigation` service is pres
<details>
<summary>Implementation internals — click to expand</summary>
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu` and supply the entries, their pin state, `onOpen`, `onPinnedChange`, and `onDismiss` as owner props. The rows call `onDismiss` before opening an entry or running a footer action; pin changes leave the menu open.
[`apply`](src/client/index.ts) registers [`EntryMenuContent`](src/client/EntryMenuContent.tsx) under id `entry-customization` into `sidebar.panellist.actions`, `conversation.session.header.views.actions`, and `sidebar.right.tab.guide.actions` through `ctx.slots.inject`, so each registration follows its region's declaration lifetime. The regions render these slots inside their own `Menu`; the rows call `onDismiss` before browsing or creating. Only the Conversation menu consumes its owner’s folded entry list and navigation callback; pin callbacks remain unused.
Each registration's `inject` supplies its region and two callbacks. Each callback first calls `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`. Browsing then calls `ctx.get('pluginNavigation')?.openList()`; ui-plugin-manager provides that service only while the Plugins page has access, so this package reads it optionally and never lists it in `inject`. Creation then calls `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })` with the region's localized prompt; it does not go through `ui-agent-preset`, so the preset chip plays no introduction. The [`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) mounts the browser plugin.
@@ -65,12 +65,12 @@ None; the package neither assembles nor sends model requests.
<a id="known-limitations-and-deferred-work"></a>
- If this package is disabled or fails to load after entries were unpinned, those launchers stay hidden and no More menu can restore them. Re-enable the package or clear the region's `dsh.slot.manual-order.*.v1` localStorage key.
- Pin choices live in browser localStorage, which is isolated by protocol, host, and port: changing the Web port or switching between Web and Desktop does not carry them over, and they do not sync across devices.
- More menus provide no visibility controls. Region pin callbacks remain available to other registrations and change only in-memory choices; plugin reload restores defaults.
- Legacy pin values in localStorage are neither read nor written.
- **Create your plugin** does not check whether the Creator preset exists. Without it, Session creation fails, the Workspace shows its creation-failure notice, and no draft is prepared. Draft preparation also requires a resolvable Workspace; without one, the existing flow reports that a Workspace is required.
- Entries keep registration order; the menus offer no reordering, and entries cannot move between regions.
- Conversation owns the estimated tab split; these menus provide no sorting.
- Conversation controls follow the existing nonblank-Session header and Coding Tools eligibility. Right-sidebar controls require a selected Session and appear on its guide page.
- Only active registered launchers appear; the menus are not an inventory of disabled or uninstalled plugins.
- The menus do not list plugins; use the browsing action to open the Plugins page.
<a id="dev-note"></a>
### Dev Note
@@ -1,5 +1,5 @@
---
description: "为三个导航区域的“更多”菜单提供 Pin 控件、插件浏览与创造模式草稿。"
description: "为三个导航区域的更多菜单提供插件浏览与创造模式草稿。"
kind: "package-reference"
---
@@ -11,7 +11,7 @@ kind: "package-reference"
## 概述
为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供内容:各区域可自定义的入口、Pin 按钮、插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮并保存自己的 Pin 状态;本包不保存状态。
为左侧栏、会话 View 标签和右侧栏起始页的“更多”菜单提供插件浏览与可审阅的创造模式草稿。Web 应用默认加载本包。各区域自己画“更多”按钮;本包不保存状态。
## 目录
@@ -28,13 +28,13 @@ kind: "package-reference"
`@deepseek-ai/dsh-web-app` 组合包默认加载本包,无需在插件页开启或由 profile 选择。区域只在其 actions slot 有注册项时显示“更多”按钮,因此卸载本包会移除三个按钮。
打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。每个菜单按注册顺序列出该区域可自定义的入口;插件管理、Chat 和工作区文件始终显示,不列入菜单。点击入口名称会关闭菜单并打开该入口,不改变 Pin 状态。独立的 Pin 按钮在区域中固定或收起该入口,并保持菜单打开;实心 Pin 表示已固定。已收起的入口仍可从菜单打开,Pin 状态在刷新后保留。过长的入口名称会换行,更多工具菜单中则截断为单行。
打开左侧导航入口下方的**更多**、会话 View 标签之后的**更多会话视图**,或右侧栏起始页卡片之后的**更多工具**。左侧栏和起始页菜单仅提供**浏览更多插件**(可用时)与**创建你的插件**。会话菜单在这些操作之前列出收起的 View,并在当前 View 右侧显示勾选标记。所有菜单都不提供 Pin 或排序操作。
**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。当前区域没有可自定义入口时,两项操作仍然可用;空列表不添加分隔线。
**浏览更多插件**进入插件页主列表,不打开安装弹窗。**创建你的插件**用 Creator 预设打开新 Session 或可复用的空白 Session,并把对应区域的简短本地化插件创建请求加入草稿:空草稿直接以该请求为文字,已有草稿保留其文字、引用和附件,请求另起一段追加在后面。它绝不发送消息。请自行审阅并发送草稿。无论区域是否有入口,创建操作始终可用;分隔线仅显示在收起的 View 与操作之间,浏览可用时位于浏览之前,否则位于创建之前。
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。打开入口和更改 Pin 状态不上报,菜单也不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
在桌面端,**浏览更多插件**与**创建你的插件**在执行前上报 `more_menu_item_click`,`action` 为 `browse_plugins` 或 `create_plugin`,`region` 为菜单所在区域:**更多**为 `sidebar`,**更多会话视图**为 `conversation`,**更多工具**为 `rightbar`。菜单不上报曝光事件。没有埋点服务时,两项操作行为不变且不上报。
「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。
「浏览插件」一行只在 `pluginNavigation` 服务存在时出现;没有该服务时插件照常启动。该服务缺失时仅移除浏览操作,不影响“更多”按钮、收起的 View 或创建操作。
-----
@@ -44,7 +44,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot,并以 owner props 提供入口、Pin 状态、`onOpen`、`onPinnedChange` 与 `onDismiss`。菜单行先调用 `onDismiss`,再打开入口或执行底部操作;Pin 变化不关闭菜单。
[`apply`](src/client/index.ts) 通过 `ctx.slots.inject` 以 id `entry-customization` 把 [`EntryMenuContent`](src/client/EntryMenuContent.tsx) 注册到 `sidebar.panellist.actions`、`conversation.session.header.views.actions` 和 `sidebar.right.tab.guide.actions`,每个注册项跟随所在区域声明的生命周期。各区域在自己的 `Menu` 中渲染这些 slot;菜单行先调用 `onDismiss`,再执行浏览或创建操作。仅会话菜单消费 owner 提供的收起入口列表及导航回调;Pin 回调不被消费。
每个注册项的 `inject` 提供所属区域与两个回调。每个回调先调用 `ctx.get('productAnalytics')?.track('more_menu_item_click', { region, action })`。浏览随后调用 `ctx.get('pluginNavigation')?.openList()`;ui-plugin-manager 只在插件页有权限时提供该服务,因此本包按可选方式读取,不把它写进 `inject`。创建随后按区域取本地化提示语,调用 `uiWorkspace.startSession(undefined, { agentPreset: 'cordis', prompt, whenDraftExists: 'append' })`;该调用不经过 `ui-agent-preset`,预设标签不播放介绍动画。[`dsh-web-app` bundle patch](../../bundle/web-app/cordis.patch.yml) 挂载浏览器插件。
@@ -65,12 +65,12 @@ kind: "package-reference"
<a id="known-limitations-and-deferred-work"></a>
- 用户收起入口后,若本包被停用或加载失败,被收起的入口保持隐藏,且没有“更多”菜单可以恢复。重新启用本包或清除对应区域的 `dsh.slot.manual-order.*.v1` localStorage 键即可恢复。
- Pin 状态保存在浏览器 localStorage,按协议、主机和端口隔离:更换 Web 端口或在 Web 与 Desktop 之间切换时不共享,也不跨设备同步。
- 更多菜单不提供入口显隐管理。其他注册项仍可调用区域 Pin 回调,仅修改内存中的选择;插件重新加载后恢复默认值。
- 不读取或写入 localStorage 中的旧 Pin 值。
- **创建你的插件**不检查 Creator 预设是否存在。预设缺失时 Session 创建失败,Workspace 显示创建失败提示,不准备草稿。草稿准备还要求可确定目标 Workspace;没有目标时,现有流程提示需要 Workspace。
- 入口保持注册顺序;菜单不提供排序,入口也不能跨区域移动。
- 会话区域负责按估算值分组;菜单不提供排序。
- 会话控件遵循现有非空 Session 头部与代码工作工具的可用性规则。右侧栏控件要求已选择 Session,并显示在其起始页。
- 仅显示已激活、已注册的入口;菜单不是已停用或未安装插件的清单。
- 菜单不列出插件清单;使用浏览操作进入插件页。
<a id="dev-note"></a>
### 开发备注
@@ -1,12 +1,3 @@
.entryRow { position: relative; }
.entryName { display: flex; align-items: center; gap: 8px; padding-right: 28px; }
.entryName { display: flex; align-items: center; gap: 8px; }
.entryTitle { min-width: 0; overflow-wrap: anywhere; white-space: normal; }
.singleLine .entryTitle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pinAnchor { position: absolute; right: 6px; top: 0; bottom: 0; display: flex; align-items: center; }
.pinButton { width: 22px; height: 22px; padding: 0; color: var(--dsw-alias-label-tertiary); }
@media (hover: hover) {
.pinButton:hover { color: var(--dsw-alias-label-primary); }
.pinAnchor[data-pinned='false'] { opacity: 0; }
.entryRow:hover .pinAnchor, .entryRow:focus-within .pinAnchor { opacity: 1; }
}
.activeMark { flex: none; margin-left: auto; }
@@ -1,17 +1,17 @@
/** Rows of a navigation region's More menu: customizable entries, plugin discovery while reachable, and creation. */
/** Folded Conversation Views, plugin discovery while reachable, and creation. */
import {
IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
IconCheckOutlineRegular, IconPluginPinwheelOutlineRegular, IconPlusOutlineRegular, MenuItemButton,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import { EntryPickerRow } from './EntryPickerRow.tsx'
import css from './EntryMenu.module.css'
/** Navigation region whose More menu hosts one registration. */
export type EntryRegion = 'sidebar' | 'conversation' | 'rightbar'
/** Region identity and footer actions injected into each registration. */
export interface EntryMenuInjected {
/** Region of this registration; selects pin copy and the Creator prompt. */
/** Region of this registration; selects analytics attributes and the Creator prompt. */
readonly region: EntryRegion
readonly hooks: {
/** Whether the Plugins page navigation service is present; the discovery row shows only while it is. */
@@ -28,27 +28,28 @@ type EntryMenuContentProps = PropsRuntime<'sidebar.panellist.actions'>
& PropsLocale<'entryCustomization'> & InjectFace<EntryMenuInjected>
/**
* Render the region's entries with pin actions, then the discovery and Creator rows.
* @param props - Owner entries and callbacks, injected region actions, and localized copy.
* Render folded Conversation Views followed by discovery and Creator actions, without pin controls.
* @param props - Owner dismissal callback, injected actions, and localized copy.
* @returns Menu rows for the region's More menu.
*/
export function EntryMenuContent({
entries, onOpen, onPinnedChange, onDismiss, region, usePluginNavigation, browsePlugins, createPlugin, t,
entries, onOpen, region, onDismiss, usePluginNavigation, browsePlugins, createPlugin, t,
}: EntryMenuContentProps) {
const moreEntries = region === 'conversation' ? entries : []
const browsable = usePluginNavigation(present => present)
return <>
{entries.map((entry) => {
const pinLabel = t(`${entry.pinned ? 'unpin' : 'pin'}.${region}`)
return <EntryPickerRow key={entry.id} label={entry.label} pinned={entry.pinned} pinLabel={pinLabel}
pinAccessibleLabel={t('pinLabel', { name: entry.label, action: pinLabel })} singleLine={region === 'rightbar'}
onOpen={() => { onDismiss(); onOpen(entry.id) }}
onPin={() => { onPinnedChange(entry.id, !entry.pinned) }} />
})}
{browsable && <MenuItemButton icon={<IconPluginPinwheelOutlineRegular size={16} />} separatorBefore={entries.length > 0}
{moreEntries.map(entry => <MenuItemButton key={entry.id} current={entry.active === true}
onSelect={() => { onDismiss(); onOpen(entry.id) }}>
<span className={css.entryName}>
<span className={css.entryTitle}>{entry.label}</span>
{entry.active === true && <IconCheckOutlineRegular size={16} className={css.activeMark} />}
</span>
</MenuItemButton>)}
{browsable && <MenuItemButton separatorBefore={moreEntries.length > 0} icon={<IconPluginPinwheelOutlineRegular size={16} />}
onSelect={() => { onDismiss(); browsePlugins() }}>
{t('install')}
</MenuItemButton>}
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} separatorBefore={!browsable && entries.length > 0}
<MenuItemButton icon={<IconPlusOutlineRegular size={16} />} separatorBefore={!browsable && moreEntries.length > 0}
onSelect={() => { onDismiss(); createPlugin() }}>
{t('create')}
</MenuItemButton>
@@ -1,42 +0,0 @@
/** A launcher row separates navigation from its independently focusable pin action. */
import clsx from 'clsx'
import {
Button, IconPinFillRegular, IconPinOutlineRegular, MenuItemButton, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import css from './EntryMenu.module.css'
interface EntryPickerRowProps {
readonly label: string
readonly pinned: boolean
readonly pinLabel: string
readonly pinAccessibleLabel: string
/** Truncate the label to one line instead of wrapping it. */
readonly singleLine: boolean
readonly onOpen: () => void
readonly onPin: () => void
}
/**
* Render two sibling controls so pinning never opens the entry.
* @param props - Entry label, pin state, localized action names and independent callbacks.
* @returns A shared menu row with a trailing pin button.
*/
export function EntryPickerRow({
label, pinned, pinLabel, pinAccessibleLabel, singleLine, onOpen, onPin,
}: EntryPickerRowProps) {
return <div className={clsx(css.entryRow, singleLine && css.singleLine)}>
<MenuItemButton onSelect={onOpen}>
<span className={css.entryName}>
<span className={css.entryTitle}>{label}</span>
</span>
</MenuItemButton>
<span className={css.pinAnchor} data-pinned={pinned}>
<Tooltip label={pinLabel} side="bottom" align="end" delayMs={500} portal>
<Button size="sm" className={css.pinButton} role="menuitem" aria-label={pinAccessibleLabel}
onClick={(event) => { event.stopPropagation(); onPin() }}>
{pinned ? <IconPinFillRegular size={14} /> : <IconPinOutlineRegular size={14} />}
</Button>
</Tooltip>
</span>
</div>
}
@@ -14,7 +14,7 @@ import { en, ja, zh } from './locales.ts'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface LocaleNamespaceMap {
/** More menu rows: pin actions, footer actions, and Creator draft prompts. */
/** More menu rows: navigation actions, and Creator draft prompts. */
entryCustomization: keyof typeof en
}
}
@@ -1,12 +1,5 @@
/** Copy for the More menu rows: pin actions, footer actions, and Creator draft prompts per region. */
/** Copy for the More menu rows: navigation actions, and Creator draft prompts per region. */
export const en = {
'pin.sidebar': 'Pin to left sidebar',
'unpin.sidebar': 'Unpin from left sidebar',
'pin.conversation': 'Pin to conversation tabs',
'unpin.conversation': 'Unpin from conversation tabs',
'pin.rightbar': 'Pin to right sidebar',
'unpin.rightbar': 'Unpin from right sidebar',
pinLabel: '{action}: {name}',
install: 'Browse other plugins',
create: 'Create your plugin',
'createPrompt.sidebar': 'Create a DSH plugin with a left sidebar entry.',
@@ -16,13 +9,6 @@ export const en = {
/** Chinese counterpart of the entry-customization dictionary. */
export const zh: Record<keyof typeof en, string> = {
'pin.sidebar': '固定到左侧栏',
'unpin.sidebar': '从左侧栏取消固定',
'pin.conversation': '固定到会话标签栏',
'unpin.conversation': '从会话标签栏取消固定',
'pin.rightbar': '固定到右侧栏',
'unpin.rightbar': '从右侧栏取消固定',
pinLabel: '{action}:{name}',
install: '浏览更多插件',
create: '创建你的插件',
'createPrompt.sidebar': '创建一个带左侧栏入口的 DSH 插件。',
@@ -32,13 +18,6 @@ export const zh: Record<keyof typeof en, string> = {
/** Japanese copy, checked against the English keys. */
export const ja = {
'pin.sidebar': '左サイドバーに固定',
'unpin.sidebar': '左サイドバーから固定解除',
'pin.conversation': '会話タブに固定',
'unpin.conversation': '会話タブから固定解除',
'pin.rightbar': '右サイドバーに固定',
'unpin.rightbar': '右サイドバーから固定解除',
'pinLabel': '{action}: {name}',
'install': '他のプラグインを探す',
'create': 'プラグインを作成',
'createPrompt.sidebar': '左サイドバーにエントリを持つ DSH プラグインを作成します。',
@@ -1,5 +1,5 @@
// @vitest-environment jsdom
/** The plugin fills each region's More menu with entry rows and the discovery and Creator actions. */
/** The plugin fills each region's More menu with the discovery and Creator actions. */
import type { Context } from '@deepseek-ai/cordis'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
@@ -30,10 +30,9 @@ function content(overrides: Partial<ContentProps> = {}) {
onDismiss: vi.fn<ContentProps['onDismiss']>(), browsePlugins: vi.fn<ContentProps['browsePlugins']>(),
createPlugin: vi.fn<ContentProps['createPlugin']>(),
}
// Framework hooks stay unused stubs; this isolated row set reads only its owner, inject, and locale shares.
const props = {
entries: [{ id: 'notes', label: 'Notes', pinned: true }, { id: 'tasks', label: 'Tasks', pinned: false }],
region: 'sidebar', t: makeTranslate(en), renderFactorySlot: unused,
region: 'sidebar', t: makeTranslate(en),
usePluginNavigation: <T,>(select: (present: boolean) => T): T => select(true),
usePanelInfo: unused, useSessions: unused, useSessionStatus: unused, useSessionRetainInfo: unused,
useResource: unused, useWorkspaces: unused,
@@ -45,54 +44,59 @@ function content(overrides: Partial<ContentProps> = {}) {
}
describe('More menu rows', () => {
it('separates opening from pinning and reports the inverted pin state', () => {
const b = content()
expect(b.view.getAllByRole('menuitem').map(row => row.getAttribute('aria-label') ?? row.textContent)).toEqual([
'Notes', 'Unpin from left sidebar: Notes', 'Tasks', 'Pin to left sidebar: Tasks', 'Browse other plugins', 'Create your plugin',
])
expect(b.view.getAllByRole('separator')).toHaveLength(1)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
fireEvent.click(b.view.getByRole('menuitem', { name: 'Pin to left sidebar: Tasks' }))
expect(b.onPinnedChange).toHaveBeenNthCalledWith(1, 'notes', false)
expect(b.onPinnedChange).toHaveBeenNthCalledWith(2, 'tasks', true)
expect(b.onDismiss).not.toHaveBeenCalled()
expect(b.onOpen).not.toHaveBeenCalled()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' }))
expect(b.onDismiss).toHaveBeenCalledOnce()
expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks')
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!)
})
it.each([
['conversation', 'Pin to conversation tabs: Tasks'],
['rightbar', 'Pin to right sidebar: Tasks'],
] as const)('names the %s pin destination', (region, name) => {
const { view } = content({ region })
expect(view.getByRole('menuitem', { name })).toBeTruthy()
})
it('keeps only the discovery and Creator actions, without a separator, for an empty roster', () => {
const b = content({ entries: [], region: 'rightbar' })
it.each(['sidebar', 'rightbar'] as const)('shows only discovery and creation in %s regardless of entries', (region) => {
const b = content({ region, entries: [
{ id: 'notes', label: 'Notes', pinned: true, active: true },
{ id: 'tasks', label: 'Tasks', pinned: false },
] })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Browse other plugins', 'Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.browsePlugins).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.browsePlugins.mock.invocationCallOrder[0]!)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith()
expect(b.onDismiss).toHaveBeenCalledTimes(2)
expect(b.onDismiss.mock.invocationCallOrder[1]).toBeLessThan(b.createPlugin.mock.invocationCallOrder[0]!)
expect(b.onOpen).not.toHaveBeenCalled()
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
it('shows the pin hint after the shared tooltip delay outside the menu card', () => {
vi.stubGlobal('matchMedia', vi.fn((query: string) => ({ matches: query === '(any-hover: hover)' })))
const { view } = content()
const unpin = view.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' })
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
fireEvent.pointerEnter(unpin, { pointerType: 'mouse' })
act(() => { vi.advanceTimersByTime(499) })
expect(view.queryByRole('tooltip', { hidden: true })).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
expect(view.getByRole('tooltip', { hidden: true }).closest('[role="menu"]')).toBeNull()
fireEvent.pointerLeave(unpin, { pointerType: 'mouse' })
it.each([true, false])('opens folded Views and marks the active View with plugin navigation present=%s', (browsable) => {
const b = content({ region: 'conversation',
usePluginNavigation: select => select(browsable),
entries: [
{ id: 'notes', label: 'Notes', pinned: true, active: true },
{ id: 'tasks', label: 'Tasks', pinned: true, active: false },
],
})
expect(b.view.getAllByRole('menuitem').map(row => row.textContent))
.toEqual(['Notes', 'Tasks', ...(browsable ? ['Browse other plugins'] : []), 'Create your plugin'])
const currentRow = b.view.getByRole('menuitem', { name: 'Notes' })
expect(currentRow.getAttribute('aria-current')).toBe('page')
expect(currentRow.querySelector('svg')?.previousElementSibling?.textContent).toBe('Notes')
expect(b.view.getByRole('menuitem', { name: 'Tasks' }).hasAttribute('aria-current')).toBe(false)
expect(b.view.getAllByRole('separator')).toHaveLength(1)
fireEvent.click(b.view.getByRole('menuitem', { name: 'Tasks' }))
expect(b.onOpen).toHaveBeenCalledExactlyOnceWith('tasks')
expect(b.onDismiss.mock.invocationCallOrder[0]).toBeLessThan(b.onOpen.mock.invocationCallOrder[0]!)
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
it.each([true, false])('keeps actions unseparated for an empty roster with plugin navigation present=%s', (browsable) => {
const b = content({ region: 'conversation', entries: [], usePluginNavigation: select => select(browsable) })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent))
.toEqual([...(browsable ? ['Browse other plugins'] : []), 'Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
})
it.each(['sidebar', 'rightbar'] as const)('keeps only creation in %s without plugin navigation', (region) => {
const b = content({ region, usePluginNavigation: select => select(false) })
expect(b.view.getAllByRole('menuitem').map(row => row.textContent)).toEqual(['Create your plugin'])
expect(b.view.queryByRole('separator')).toBeNull()
fireEvent.click(b.view.getByRole('menuitem', { name: 'Create your plugin' }))
expect(b.createPlugin).toHaveBeenCalledExactlyOnceWith()
expect(b.onOpen).not.toHaveBeenCalled()
expect(b.onPinnedChange).not.toHaveBeenCalled()
})
})
@@ -103,7 +107,7 @@ function Frame({ renderSlot }: PropsRenderSlots<Seats>) {
return <>
{SEATS.map(seat => <div key={seat} role="menu" aria-label={seat}>
{renderSlot(seat, {
entries,
entries: seat === 'conversation.session.header.views.actions' ? [] : entries,
onOpen: (id) => { setOpened(`${seat}:${id}`) },
onPinnedChange: (_id, next) => { setPinned(next) },
onDismiss: () => {},
@@ -148,8 +152,7 @@ describe('More menu registrations', () => {
const sidebar = () => within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
expect(sidebar().queryByRole('menuitem', { name: 'Browse other plugins' })).toBeNull()
expect(sidebar().getByRole('menuitem', { name: 'Create your plugin' })).toBeTruthy()
// The Creator row takes the separator the absent discovery row would carry.
expect(sidebar().getAllByRole('separator')).toHaveLength(1)
expect(sidebar().queryByRole('separator')).toBeNull()
const injected = b.runtime.slots.entries('sidebar.panellist.actions')[0]!.inject!() as { browsePlugins: () => void }
expect(() => { injected.browsePlugins() }).not.toThrow()
@@ -170,15 +173,10 @@ describe('More menu registrations', () => {
const b = await bench()
for (const seat of SEATS) {
expect(b.runtime.slots.entries(seat).map(entry => entry.options.id)).toEqual(['entry-customization'])
expect(within(b.view.getByRole('menu', { name: seat })).getByRole('menuitem', { name: 'Notes' })).toBeTruthy()
expect(within(b.view.getByRole('menu', { name: seat })).getAllByRole('menuitem').map(row => row.textContent))
.toEqual(['Browse other plugins', 'Create your plugin'])
}
const sidebar = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
fireEvent.click(sidebar.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
expect(within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' }))
.getByRole('menuitem', { name: 'Pin to right sidebar: Notes' })).toBeTruthy()
fireEvent.click(within(b.view.getByRole('menu', { name: 'conversation.session.header.views.actions' }))
.getByRole('menuitem', { name: 'Notes' }))
expect(b.view.getByTestId('opened').textContent).toBe('conversation.session.header.views.actions:notes')
fireEvent.click(sidebar.getByRole('menuitem', { name: 'Browse other plugins' }))
expect(b.openList).toHaveBeenCalledExactlyOnceWith()
await b.plugin.dispose()
@@ -220,15 +218,6 @@ describe('More menu registrations', () => {
expect(b.track).toHaveBeenCalledTimes(2)
})
it('reports neither entry opening nor pin changes', async () => {
const b = await bench()
const menu = within(b.view.getByRole('menu', { name: 'sidebar.panellist.actions' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin from left sidebar: Notes' }))
fireEvent.click(menu.getByRole('menuitem', { name: 'Notes' }))
expect(b.view.getByTestId('opened').textContent).toBe('sidebar.panellist.actions:notes')
expect(b.track).not.toHaveBeenCalled()
})
it('keeps both footer actions working without the analytics service', async () => {
const b = await bench('en', { analytics: false })
const menu = within(b.view.getByRole('menu', { name: 'sidebar.right.tab.guide.actions' }))
@@ -1,20 +0,0 @@
/** Pin controls use Session-action ink without changing the navigation control's dimensions. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const css = readFileSync(new URL('../src/client/EntryMenu.module.css', import.meta.url), 'utf8')
describe('entry pin styling', () => {
it('uses tertiary ink for both pin states and primary ink on pointer hover', () => {
expect(css).toMatch(/\.pinButton\s*\{[^}]*color:\s*var\(--dsw-alias-label-tertiary\)/)
expect(css).toMatch(/\.pinButton:hover\s*\{[^}]*color:\s*var\(--dsw-alias-label-primary\)/)
expect(css).not.toMatch(/\[data-pinned=['"]true['"]\]\s+\.pinButton\s*\{[^}]*color:/)
})
it('centers the pin without creating a transformed tooltip container', () => {
const pinAnchor = css.match(/\.pinAnchor\s*\{([^}]*)\}/)?.[1]
expect(pinAnchor).toBeDefined()
expect(pinAnchor).toContain('align-items: center')
expect(pinAnchor).not.toContain('transform:')
})
})
@@ -18,11 +18,11 @@
en: 10be7f75a1576575
zh: abf2905d89b0da48
/deepseek-ai-dsh-client-ui-primitives/use-this-package/component-catalog:
en: 75e1681dcfc61a14
zh: 3eda3143730f83e1
en: 3c79351ff2210ccb
zh: 4cbe07ded89bbaa1
/deepseek-ai-dsh-client-ui-primitives/use-this-package/controls-and-icons:
en: dbd7f0e74c45a762
zh: 7efbfbf5500cc835
en: b3744373b4b2c1fb
zh: 2dbde8ee0bfa5f28
/deepseek-ai-dsh-client-ui-primitives/use-this-package/rendering-agent-output:
en: 02f310acbae29955
zh: 1e41393d5d714b2d
+3 -3
View File
@@ -49,7 +49,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `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. |
| `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. `MenuItemButton.current` exposes the current page or view through `aria-current` without changing selection or appearance. 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. Default `size="md"` is 42px tall with a 4px inset; `size="sm"` is 34px tall with a 2px inset. |
@@ -96,9 +96,9 @@ 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 icon renders an `<svg>` box with one `<use>` of its `dsw-icon-<glyph>-<weight>` 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 `<use>` 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, capsLine truncated
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 `<svg>` box with one `<use>` of its `dsw-icon-<glyph>-<weight>` 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 `<use>` 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; while an anchor is inside that top strip, its overlay, including a `HoverCard`, is never pushed lower than the anchor's bottom edge plus the overlay's gap. `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 minuLine truncated
`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.
`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. Opt-in `onlyWhenClipped` shows the bubble only when the anchor or an HTML element inside it overflows horizontally when the bubble is about to show, so a label that repeats ellipsis-cut text appears only while it adds information.
<a id="rendering-agent-output"></a>
+3 -3
View File
@@ -49,7 +49,7 @@ kind: "package-library"
| `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` 仅决定打开时是否聚焦首行。 |
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。`MenuItemButton.current` 通过 `aria-current` 标识当前页面或视图,不改变选中状态或外观。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
| `MenuGroup`、`observeStickyMenuGroups` | 为自定义菜单与列表框提供本地化、可访问的分组,共用吸顶标题样式与原生交叉/尺寸观察;调用方负责观察器的生命周期。 |
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。默认 `size="md"` 总高 42px、内边距 4px;`size="sm"` 总高 34px、内边距 2px。 |
@@ -96,9 +96,9 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `<svg>` 框,其中包含一个引用对应 `dsw-icon-<glyph>-<weight>` symbol 的 `<use>`,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `<use>` 引用的图形会从使用处继承 `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;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`HoveLine truncated
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸。具名组件让所选线重在导入处可见;描边几何由组件库拥有,而不是由消费方 CSS 覆盖。每个产品图标渲染一个 `<svg>` 框,其中包含一个引用对应 `dsw-icon-<glyph>-<weight>` symbol 的 `<use>`,例如 `dsw-icon-search-outline-regular`,并在 `data-icon` 中写出该 id。`ICON_SYMBOLS` 保存每个 symbol 的默认 SVG 文档;ui-theme 据此渲染文档的图标 sprite,缺少对应 symbol 的图标不渲染任何内容。通过 `<use>` 引用的图形会从使用处继承 `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`)不会被推到低于锚点底边加浮层间距的位置。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。触摸进入不会打开预览,也不会拦截锚点操作。鼠标进入要求存在可悬停的指针设备;键盘聚焦不依赖该能力。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区(位于顶部区域内的锚点下方时除外),高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收Line truncated
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前保持隐藏,优先使用指定方向,再尝试相反方向和垂直于该方向的两侧;只要某一侧容得下,就沿锚点平移而不遮挡锚点。左右放置保留 10px 间距,上下放置使用 `gap`(默认 8px)。宽高限制在视口内并保留 12px 边距;文本自动换行,超出视口高度的内容在固定气泡后可滚动。若所有方向都放不下,则使用稳定的视口内回退位置,可能遮挡锚点。视口尺寸变化会重新读取锚点坐标,标签尺寸变化则复用坐标;定位不会同步测量气泡,也不会触发 React 渲染。 触摸按下会关闭未固定的提示,且不会触发悬停提示,包括触摸后的兼容鼠标事件。只有 `(any-hover: hover)` 表明存在可悬停的输入设备时,鼠标进入才会打开提示,从而排除 iOS 因已点击图标变化而生成的鼠标进入事件。仅有触摸输入的设备仍可使用键盘聚焦提示和显式点击打开的说明。信息按钮可显式启用 `openOnClick`,通过鼠标或触摸点击固定气泡供阅读,并将内容关联为锚点的无障碍描述;再次点击、Escape、Tab 或外部 pointerdown 会关闭。普通操作按钮在触摸点击时直接执行操作,不显示 Tooltip。可选的 `onlyWhenClipped` 只在气泡即将显示时锚点或其内部某个 HTML 元素横向溢出才显示气泡,因此重复被省略号截断文本的标签只在能补充信息时出现。
<a id="rendering-agent-output"></a>
@@ -33,7 +33,8 @@ interface CardPosition {
* readable and selectable, but it carries no dismissal affordance of its own.
* @param props.openDelayMs - hover dwell before the card shows (default 500).
* @param props.variant - compact card beside the anchor, or a preview above/below it
* with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions.
* with 24px side insets, a 420px height cap, the frame-top clearance (capped below an anchor inside the top strip),
* and 100ms opacity transitions.
* @param props.widthAnchorRef - optional element whose width and horizontal position size the preview.
* @param props.inline - keep the anchor in prose; show a contained preview on hover or keyboard focus.
* Its hover region connects the anchor and preview across the visual gap.
@@ -178,7 +179,7 @@ export function HoverCard({
if (variant === 'preview') {
const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r
const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2))
const topMargin = overlayTopMargin(VIEWPORT_MARGIN)
const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP)
const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP)
const above = Math.max(0, r.top - ANCHOR_GAP - topMargin)
const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN)
@@ -195,7 +196,7 @@ export function HoverCard({
const height = Math.max(h, cardRef.current?.scrollHeight ?? 0)
const maxWidth = Math.max(0, Math.min(INLINE_PREVIEW_WIDTH, window.innerWidth - VIEWPORT_MARGIN * 2))
const width = fitContent ? Math.min(cardRef.current?.offsetWidth ?? maxWidth, maxWidth) : maxWidth
const topMargin = overlayTopMargin(VIEWPORT_MARGIN)
const topMargin = overlayTopMargin(VIEWPORT_MARGIN, r, ANCHOR_GAP)
const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP)
const above = Math.max(0, r.top - ANCHOR_GAP - topMargin)
const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN)
+10 -5
View File
@@ -51,6 +51,8 @@ export interface MenuItemButtonProps {
shortcut?: MenuItem['shortcut']
/** Leading icon (figma .Menu_cell gap 8). */
icon?: ReactNode
/** Whether this row identifies the currently displayed page or view. */
current?: boolean
/** Whether the row cannot be activated. */
disabled?: boolean
/** Destructive row: error-colored text/icon and danger hover fill. */
@@ -75,6 +77,7 @@ export interface MenuItemButtonProps {
* @param props.children - visible row label.
* @param props.shortcut - effective key labels and accessible combination.
* @param props.icon - optional leading icon.
* @param props.current - whether the row identifies the currently displayed page or view.
* @param props.disabled - whether the row cannot be activated.
* @param props.danger - whether to use the destructive row colors.
* @param props.separatorBefore - whether this row starts a new group (hairline above it).
@@ -82,7 +85,7 @@ export interface MenuItemButtonProps {
* @returns one menu-item row.
*/
export function MenuItemButton({
children, shortcut, icon, disabled = false, danger = false, separatorBefore = false, onSelect,
children, shortcut, icon, current, disabled = false, danger = false, separatorBefore = false, onSelect,
}: MenuItemButtonProps) {
return (
<div className={css.itemWrap}>
@@ -93,6 +96,7 @@ export function MenuItemButton({
className={clsx(css.item, danger && css.danger)}
disabled={disabled}
aria-keyshortcuts={shortcut?.aria}
aria-current={current === true ? 'page' : undefined}
onClick={onSelect}
>
{icon !== undefined && <span className={css.itemIcon}>{icon}</span>}
@@ -255,6 +259,7 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
}
if (r === null) return
const MARGIN = 12
const GAP = 4
const vw = window.innerWidth
const vh = window.innerHeight
const listEl = listRef.current
@@ -268,18 +273,18 @@ export function Menu({ open, anchor, items = [], children, selectedId, selectedI
let x: number
let y: number
if (side === 'right') {
x = r.right + 4
x = r.right + GAP
y = r.top
} else if (align === 'start') {
x = r.left
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP
} else {
x = r.right - lw
y = side === 'bottom' ? r.bottom + 4 : r.top - lh - 4
y = side === 'bottom' ? r.bottom + GAP : r.top - lh - GAP
}
if (lw > 0) x = Math.min(Math.max(x, MARGIN), vw - lw - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN)), vh - lh - MARGIN)
if (lh > 0) y = Math.min(Math.max(y, overlayTopMargin(MARGIN, r, GAP)), vh - lh - MARGIN)
setFixedPos(current => current?.left === x && current.top === y ? current : { left: x, top: y })
}
+10 -1
View File
@@ -33,6 +33,11 @@ interface AnchorProps {
type TooltipLabel = string | (() => string)
function clipped(anchor: HTMLElement): boolean {
return [anchor, ...anchor.querySelectorAll<HTMLElement>('*')]
.some(el => el instanceof HTMLElement && el.scrollWidth > el.clientWidth)
}
/**
* Attach a hover/focus tooltip to an anchor element.
* @param props.label - bubble text, or a resolver evaluated only while visible; an empty string shows only shortcut keys.
@@ -54,6 +59,9 @@ type TooltipLabel = string | (() => string)
* half-viewport cap would render a slab wider than the surface the anchor sits on; always bounded by viewport margins.
* @param props.openOnClick - clicking or tapping also pins the bubble for reading; another click, Escape,
* Tab, or an outside pointerdown dismisses it. Defaults to false for ordinary action tooltips.
* @param props.onlyWhenClipped - show the bubble only when the anchor or an HTML element inside it
* overflows horizontally, measured when the bubble is about to show; for labels that repeat
* text an ellipsis may cut off. Defaults to false.
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
* @returns the cloned anchor plus a fixed-position bubble, optionally portaled to the body.
* The bubble stays hidden until ResizeObserver supplies its size for viewport fitting; clicking the
@@ -62,7 +70,7 @@ type TooltipLabel = string | (() => string)
* tooltips; tapping still invokes the child's action and can open an openOnClick explanation.
* When no side fits, the viewport-bounded fallback may overlap the anchor; oversized content is scrollable when pinned.
*/
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, openOnClick = false, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; openOnClick?: boolean; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center', delayMs = 0, focusDelayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, openOnClick = false, onlyWhenClipped = false, children }: { label: TooltipLabel; shortcutKeys?: readonly string[] | undefined; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; focusDelayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; openOnClick?: boolean; onlyWhenClipped?: boolean; children: ReactElement<AnchorProps> }) {
const id = useId()
const [pinned, setPinned] = useState(false)
const anchor = useRef<HTMLElement | null>(null)
@@ -156,6 +164,7 @@ export function Tooltip({ label, shortcutKeys, side = 'right', align = 'center',
const el = anchor.current
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
if (el === null) return
if (onlyWhenClipped && !clipped(el)) return
setPos(el.getBoundingClientRect())
announce(true)
}
@@ -3,11 +3,16 @@
/**
* Resolve the top margin an overlay keeps from the viewport edge.
* @param min - the overlay's own viewport margin in px, used as the floor.
* @returns the larger of `min` and the frame clearance plus 20px; fullscreen keeps only the 20px gap.
* @param anchor - the overlay's anchor rect in viewport coordinates. While the anchor's top edge is inside the
* viewport, the margin never exceeds `anchor.bottom + gap`, so an overlay opened from a control inside the top strip
* stays next to it.
* @param gap - the distance the overlay keeps below `anchor`.
* @returns the frame clearance plus 20px (20px in fullscreen), capped by a visible `anchor`, but never below `min`.
*/
export function overlayTopMargin(min: number): number {
export function overlayTopMargin(min: number, anchor?: { readonly top: number; readonly bottom: number }, gap = 0): number {
const root = document.documentElement
const clearance = Number.parseFloat(getComputedStyle(root).getPropertyValue('--dsh-frame-top-clearance'))
if (Number.isNaN(clearance)) return min
return Math.max(min, (root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20)
const cap = anchor !== undefined && anchor.top >= 0 ? anchor.bottom + gap : Number.POSITIVE_INFINITY
return Math.max(min, Math.min((root.hasAttribute('data-fullscreen') ? 0 : clearance) + 20, cap))
}
@@ -56,7 +56,7 @@ export function useAnchoredPosition(options: AnchoredPositionOptions): CSSProper
let left = align === 'end' ? rect.right - width : rect.left
let top = side === 'top' ? rect.top - gap - height : rect.bottom + gap
if (width > 0) left = Math.min(Math.max(left, margin), window.innerWidth - width - margin)
if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin)), window.innerHeight - height - margin)
if (height > 0) top = Math.min(Math.max(top, overlayTopMargin(margin, rect, gap)), window.innerHeight - height - margin)
/* v8 ignore stop */
setPosition({ left, top })
}
@@ -157,6 +157,21 @@ describe('Input', () => {
})
})
describe('MenuItemButton', () => {
it('exposes the current page without preventing activation and clears it when selection moves', () => {
const onSelect = vi.fn()
const { rerender } = render(<MenuItemButton current onSelect={onSelect}>Notes</MenuItemButton>)
const row = screen.getByRole('menuitem', { name: 'Notes' })
expect(row.getAttribute('aria-current')).toBe('page')
fireEvent.click(row)
expect(onSelect).toHaveBeenCalledOnce()
rerender(<MenuItemButton current={false} onSelect={onSelect}>Notes</MenuItemButton>)
expect(row.hasAttribute('aria-current')).toBe(false)
rerender(<MenuItemButton onSelect={onSelect}>Notes</MenuItemButton>)
expect(row.hasAttribute('aria-current')).toBe(false)
})
})
describe('Menu', () => {
const items = [
{ id: 'a', label: 'Alpha' },
@@ -243,7 +243,7 @@ describe('HoverCard', () => {
expect(vi.getTimerCount()).toBe(0)
})
it('keeps previews below the desktop frame clearance', () => {
it('keeps previews clear of the frame top strip except below an anchor inside it', () => {
const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance')
const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance')
document.documentElement.style.setProperty('--dsh-frame-top-clearance', '100px')
@@ -258,6 +258,10 @@ describe('HoverCard', () => {
fireEvent(window, new Event('resize'))
expect(card.style.top).toBe('120px')
expect(card.style.maxHeight).toBe('322px')
// An anchor inside the top strip keeps its preview directly below it.
wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 10, width: 600, height: 32 })
fireEvent(window, new Event('resize'))
expect(card.style.top).toBe('50px')
} finally {
if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance')
else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority)
@@ -0,0 +1,55 @@
// @vitest-environment jsdom
/** Portal menus keep the desktop frame's top clearance without leaving a gap below an anchor inside it. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, render } from '@testing-library/react'
import { Menu } from '../src/Menu.tsx'
const root = document.documentElement
let prior = ''
beforeEach(() => {
prior = root.style.getPropertyValue('--dsh-frame-top-clearance')
root.style.setProperty('--dsh-frame-top-clearance', '48px')
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
if (prior === '') root.style.removeProperty('--dsh-frame-top-clearance')
else root.style.setProperty('--dsh-frame-top-clearance', prior)
})
/**
* Open an 80px-tall portal menu from an anchor with the stated rect.
* @param anchor - the anchor rect in viewport coordinates.
* @param side - which anchor edge the menu opens from.
* @returns the render result.
*/
function openMenu(anchor: DOMRect, side: 'top' | 'bottom') {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor)
vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(120)
vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(80)
return render(<Menu open portal side={side} anchor={<button>Open</button>} items={[{ id: 'one', label: 'One' }]} onClose={vi.fn()} />)
}
describe('Menu top clearance', () => {
it('opens directly below an anchor that sits inside the top strip', () => {
const view = openMenu(new DOMRect(300, 14, 18, 22), 'bottom')
expect(view.getByRole('menu').style.top).toBe('40px')
})
it('keeps a menu that does not fit above its anchor below the top strip', () => {
const view = openMenu(new DOMRect(300, 100, 18, 22), 'top')
expect(view.getByRole('menu').style.top).toBe('68px')
})
it('places a menu that does not fit above an anchor inside the top strip directly below it', () => {
const view = openMenu(new DOMRect(300, 14, 18, 22), 'top')
expect(view.getByRole('menu').style.top).toBe('40px')
})
it('keeps the top strip clear when the anchor has scrolled above the viewport', () => {
const view = openMenu(new DOMRect(300, -30, 18, 22), 'bottom')
expect(view.getByRole('menu').style.top).toBe('68px')
})
})
@@ -765,6 +765,30 @@ describe('Tooltip', () => {
expect(screen.getByRole('tooltip')).toBeTruthy()
})
it('shows a clipped-only bubble when the anchor or a descendant overflows at show time', () => {
render(
<Tooltip label="Full label" onlyWhenClipped>
<button type="button"><svg /><span>Full…</span></button>
</Tooltip>,
)
const anchor = screen.getByRole('button')
const label = screen.getByText('Full…')
const overflowing = new Set<Element>()
vi.spyOn(Element.prototype, 'scrollWidth', 'get').mockImplementation(function (this: Element) { return overflowing.has(this) ? 120 : 80 })
vi.spyOn(Element.prototype, 'clientWidth', 'get').mockReturnValue(80)
fireEvent.pointerEnter(anchor)
expect(screen.queryByRole('tooltip')).toBeNull()
fireEvent.pointerLeave(anchor)
overflowing.add(label)
fireEvent.pointerEnter(anchor)
expect(screen.getByRole('tooltip').textContent).toBe('Full label')
fireEvent.pointerLeave(anchor)
overflowing.clear()
overflowing.add(anchor)
fireEvent.pointerEnter(anchor)
expect(screen.getByRole('tooltip').textContent).toBe('Full label')
})
it('mouse leave hides the bubble immediately, even while the anchor stays focused', () => {
render(
<Tooltip label="Sticky">
@@ -51,10 +51,12 @@ function stubResizeObserver(): Recorded[] {
* @param props - whether the panel is open.
* @returns the anchor and, while open, the panel carrying the position.
*/
function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) {
function Host({ open, align, side }: { open: boolean; align?: 'start' | 'end'; side?: 'top' | 'bottom' }) {
const anchorRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const position = useAnchoredPosition({ open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align } })
const position = useAnchoredPosition({
open, anchorRef, panelRef, gap: 4, margin: 12, ...align === undefined ? {} : { align }, ...side === undefined ? {} : { side },
})
return (
<>
<button ref={anchorRef} type="button">anchor</button>
@@ -64,7 +66,11 @@ function Host({ open, align }: { open: boolean; align?: 'start' | 'end' }) {
}
describe('useAnchoredPosition', () => {
it.each([{ fullscreen: false, top: 60 }, { fullscreen: true, top: 20 }])('keeps an anchored panel at $top with fullscreen=$fullscreen', ({ fullscreen, top }) => {
it.each([
{ name: 'a panel that does not fit above its anchor', side: 'top', anchor: new DOMRect(100, 100, 200, 10), fullscreen: false, top: 60 },
{ name: 'a panel that does not fit above its anchor', side: 'top', anchor: new DOMRect(100, 100, 200, 10), fullscreen: true, top: 20 },
{ name: 'a panel below an anchor inside the top strip', side: 'bottom', anchor: new DOMRect(100, 0, 200, 10), fullscreen: false, top: 14 },
] as const)('keeps $name at $top with fullscreen=$fullscreen', ({ side, anchor, fullscreen, top }) => {
const root = document.documentElement
const previous = root.getAttribute('style')
const priorFullscreen = root.getAttribute('data-fullscreen')
@@ -77,10 +83,10 @@ describe('useAnchoredPosition', () => {
})
root.style.setProperty('--dsh-frame-top-clearance', '40px')
root.toggleAttribute('data-fullscreen', fullscreen)
vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 0, 200, 10))
vi.spyOn(HTMLButtonElement.prototype, 'getBoundingClientRect').mockReturnValue(anchor)
vi.spyOn(HTMLDivElement.prototype, 'offsetHeight', 'get').mockReturnValue(400)
vi.stubGlobal('innerHeight', 600)
const { getByTestId } = render(<Host open />)
const { getByTestId } = render(<Host open side={side} />)
expect(getByTestId('panel').style.top).toBe(`${top}px`)
})
+4 -4
View File
@@ -10,19 +10,19 @@
zh: eb3e5d5a802da592
/deepseek-ai-dsh-client-ui-schedule/summary:
en: 8dfc371c14bb4415
zh: 9b3545c1a7468a7a
zh: 5e2fa588983524ed
/deepseek-ai-dsh-client-ui-schedule/table-of-contents:
en: d152484eb41ac6b4
zh: 405b30feaf48c087
/deepseek-ai-dsh-client-ui-schedule/use-this-package:
en: babac791422f1b8f
zh: f633dbfc1b7bb4bd
en: fccffd577fcf93f2
zh: 4719236f7d89e0a8
/deepseek-ai-dsh-client-ui-schedule/use-this-package/time-zone-data-source:
en: 47cd16c142e82385
zh: 181849a3c7a167fd
/deepseek-ai-dsh-client-ui-schedule/understand-the-implementation:
en: 959145f9a08c3913
zh: caae642a82d2acd3
zh: 7c0afb742790c474
/deepseek-ai-dsh-client-ui-schedule/further-exploration:
en: 06e265120b05dbf5
zh: 08ab1ef979b8ea40
+3 -3
View File
@@ -25,9 +25,9 @@ Manage active and inactive tasks from the Automation tasks page and the right Si
<a id="use-this-package"></a>
## Use this package
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Its task list supports text search and one All, Enabled, and Inactive status filter row; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
The shipped Web composition mounts `ui-schedule` with the Host Schedule service. The clock context that supplies the model's current time is preset-owned: `standard`, `cordis`, and `ptc` declare it, and `minimal` declares neither it nor the reminder tools. The Automation tasks sidebar entry opens a global page without selecting or activating a Session. The page header's New action starts a new Session, where the reminder is created by asking the model; the page itself has no creation form. Once the catalog holds an inactive task, the search row's More task actions menu offers Clear all inactive tasks, which removes every inactive task after a confirmation that states how many inactive tasks the catalog currently holds. The page's task list supports text search and one Enabled, Inactive, and All status filter row, starting on Enabled; search matches the stored task name, the instruction, and the internal Session id, not a resolved Session title, and the empty state shows a decorative document-and-clock illustration above its notice and New action, with colors that follow the light or dark theme. Selecting an active or inactive task opens a second detail level with Rules and Records tabs. Every list row shows the task's stored title with its status, frequency, and next run and never the original Session name; the detail's name control shows the stored title. Rules contains the complete reminder, status, next target for active tasks, original recurrence rule, and task id. Daily rule labels show the saved local time and exact stored IANA zone, preserving nonzero seconds and milliseconds rather than substituting the browser's zone. The Linked session entry in the detail tab strip names the linked Session by its catalog title, falling back to the Session id when no title is available. When the strip cannot fit the tab list beside the labelled entry, the entry becomes an icon button with the same button style as More task actions, with the Session chat icon and a Linked session tooltip that also shows while the entry is disabled; the entry keeps focus when it switches layouts; the tab list scrolls without a visible scrollbar. It opens an available, unarchived Session after metadata loads; pending Session or Workspace metadata, an archived Session, missing Session membership, or a Workspace query error keeps it disabled with an explanation. The click rechecks availability; browsing does not unarchive the Session. Only this action requests Session activation. Deletion is available from the More task actions menu in the detail's top strip, separate from navigation.
The page header keeps the title at its natural width and moves New to another line when the two no longer fit together. The action label stays on one line. On a coarse pointer the list's search box and the detail's instruction and run-time fields take a 16px step — iOS Safari zooms the visual viewport for a focused field below that size — while the name control keeps its 20px step and a fine pointer keeps the compact steps.
The page header keeps the title at its natural width and moves the actions to another line when the title and the actions no longer fit together. Each action label stays on one line. On a coarse pointer the list's search box and the detail's instruction and run-time fields take a 16px step — iOS Safari zooms the visual viewport for a focused field below that size — while the name control keeps its 20px step and a fine pointer keeps the compact steps.
Selecting a task from the list opens Rules; refreshing that task preserves the selected tab. Rules edits an active task's name, instruction, and run time; the edits stay in a local draft until saved, and inactive tasks remain read-only. Unmodified Left/Right arrows cycle the tabs, Home/End select the first/last tab, and keyboard focus follows the selection. The tabs do not consume these keys with Alt, Ctrl, Meta, or Shift. The linked-Session control in the detail tab strip stays available on the Rules tab, and confirmed deletion is available from either view.
@@ -41,7 +41,7 @@ After the last saved page loads, a nonempty history with confirmed pruning shows
A successful empty history page is distinct from loading, query failure, a missing task, or an invalid cursor. Failed requests retain previously loaded records with a warning and Retry delivery records; an invalid cursor offers Refresh delivery records to reload the newest page. Changing the latest `messageId` refreshes the newest page without switching tabs. Responses superseded by a refresh or received after changing tasks, leaving Records, or closing details do not replace the current view.
The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, and the header popover — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. Confirming a deletion closes the detail or the tab it was confirmed from, and each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed.
The Automation tasks page requires confirmation before deleting a task. Deletion is hard: the Host removes the stored task row together with its saved delivery records, so the task stops future delivery, leaves `list` and `catalog`, and its delivery history is no longer readable. Deletion leaves the original Session and queued messages intact, and the list keeps the row visible only until an authoritative query confirms the removal. Every deletion path — the Tasks page detail, the Session task tab, the header popover, and the Tasks page's bulk cleanup — reports its settled outcome into one store that the app-wide `shell.overlay` notice renders, so the outcome outlives the panel or tab that asked for it: the notice reads `Task deleted.` once the Host removed the row with its saved delivery records, and `Could not delete the task.` after a failure, which leaves the rule in place to retry. The same notice reports a bulk cleanup: `Cleared N inactive tasks.` with the count the Host answered, `No inactive tasks to clear.` when the Host reports that it removed none, and `Could not clear tasks.` after a failure; a normal failure leaves the remaining inactive rows for a retry, while a backend `unknown-state` failure may have removed some rows and quarantines the Host's schedule domain until it is reopened. The bulk cleanup closes only its confirmation, whereas confirming a single deletion closes the detail or the tab it was confirmed from. Each notice is keyed by its own sequence so a later one restarts the banner instead of being folded into an earlier one. A query failure keeps the last known records and shows a separate Retry action; it never appears as an empty successful result. A one-shot remains stored as inactive after durable inbox delivery. Its details and original Session id remain available through the All or Inactive filter until explicit deletion. The latest delivery shows its occurrence time; the message id and delivery acknowledgment time are not shown. Neither inactive status nor that receipt means model execution completed.
An open Session renders an icon-only clock button among its header's right-aligned utilities, immediately left of the overflow menu, only once it has something to open: the first read mounts nothing, a read that returns no active reminder leaves the header without the button, a refresh keeps the last known reminders visible, and a failed read keeps the button with its retry. That rule prevents the clock from appearing and disappearing around the first answer. Its accessible name states the reminder count when the read is ready and the generic reminder label otherwise. With exactly one active reminder, the button opens that task's detail in the right Sidebar directly. With any other count it opens a popover that lists overdue reminders first, then future reminders by target time, with stored title, frequency, browser-local target time, relative time, and a Delete button; the title is a button that opens that task's detail in the right Sidebar. The task tab names the shown task's stored title on its chip and carries the same rule, delivery records, name, instruction, and run-time editing, confirmed deletion, and original-Session link as the Automation tasks page; it never activates a Session. Daily frequency text retains the saved local time and zone, and every next-target instant is shown as a device-zone clock with its relative duration in parentheses; the rule's own zone appears only in that frequency text. Escape closes the popover and returns focus to the button; an outside pointer press dismisses it. Deleting the final reminder removes the button, and an already open popover stays expandable until then.
+8 -8
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和未运行定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。
在「自动化任务」页面和右侧栏的任务页签中管理各个会话的活动和已结束定时任务:搜索、筛选、修改名称、指令和运行时间、浏览任务运行记录、删除任务,或打开原会话。每个界面都用任务已存储的标题命名任务。已打开会话在存在活动提醒时,页头显示仅图标的提醒时钟,点击在右侧栏打开该任务详情;`schedule_create` 调用把创建的任务渲染为会话卡片;侧栏中带活动任务的未归档空闲会话行显示时钟标记,其悬停卡片列出这些任务。
## 目录
@@ -25,15 +25,15 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。列表支持文本搜索和一行“全部/已开启/未运行”状态筛选;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或未运行任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
随发行版交付的 Web 组合把 `ui-schedule` 与 Host Schedule 服务一起挂载。为模型提供当前时间的时钟上下文归 preset 所有:`standard`、`cordis` 与 `ptc` 声明它,`minimal` 既不声明它,也不声明提醒工具。侧栏的自动化任务入口打开全局页面,不选择或激活会话。页面页头的“新建”操作会打开一个新会话,提醒由模型在该会话中创建;页面本身没有创建表单。目录中存在已结束任务后,搜索行右侧的「更多任务操作」菜单提供「清除全部已结束任务」,经确认后移除全部已结束任务,确认提示说明目录当前包含多少个已结束任务。列表支持文本搜索和一行“已开启/已结束/全部”状态筛选,默认停在“已开启”;搜索匹配任务已存储的名称、指令和内部会话 id,不匹配解析出的会话标题,空状态在提示和“新建”操作上方显示装饰性的文档与时钟插画,配色跟随浅色或深色主题。选择活动或已结束任务后,进入含“规则”和“任务运行记录”页签的第二层详情。每条列表行显示任务已存储的标题及其状态、频率和下次运行,不显示原会话名称;详情中的名称输入框显示任务已存储的标题。“规则”显示完整提醒、状态、活动任务的下一目标时间、原始重复规则和任务 id。每天的规则标签显示已保存的本地时间与精确的存储 IANA 时区,保留非零秒与毫秒,而不替换为浏览器时区。详情页签条内的“关联会话”入口按会话标题显示,取不到标题时回退显示会话 id。页签条放不下页签列表和带文字的入口时,入口收为与“更多任务操作”同样式的图标按钮,使用会话对话图标,悬停提示“关联会话”,入口禁用时也会显示;切换形态时保留焦点;页签列表可滚动但不显示滚动条。元数据加载后,它可打开可用且未归档的会话;会话或 Workspace 元数据尚未就绪、会话已归档、会话不在列表中或 Workspace 查询失败时,入口保持禁用并显示原因。点击时会重新检查可用性;浏览不会取消会话归档。仅此操作请求激活会话。删除位于详情顶部条带的“更多任务操作”菜单。
页头按标题的自然宽度排布;标题和“新建”无法并排容纳时,将按钮移到下一行。操作文案保持单行。粗指针设备上,列表的搜索框以及详情的指令和运行时间输入框采用 16px 字号——iOS Safari 会放大聚焦到更小字号输入框时的视觉视口——而名称输入框保留 20px、细指针设备保留紧凑字号。
页头按标题的自然宽度排布;标题和操作无法并排容纳时,将操作移到下一行。每个操作文案保持单行。粗指针设备上,列表的搜索框以及详情的指令和运行时间输入框采用 16px 字号——iOS Safari 会放大聚焦到更小字号输入框时的视觉视口——而名称输入框保留 20px、细指针设备保留紧凑字号。
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,未运行任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
从列表选择任务时默认打开“规则”;刷新同一任务会保留当前页签。活动任务的“规则”修改名称、指令和运行时间;这些修改保留在本地草稿中直到保存,已结束任务保持只读。不带修饰键的左右方向键循环切换页签,Home/End 选择第一个/最后一个页签,键盘焦点随选择移动。页签不会拦截这些键与 Alt、Ctrl、Meta 或 Shift 的组合。共用的会话入口位于详情页签条内,在“规则”页签可见;确认删除操作在两种页签下都可用。
“规则”在“运行时间”卡片中以行编辑本地草稿:“重复”是含每周、周一至周五、每天、每 N 小时、每 N 分钟、每 N 秒、仅一次和自定义(cron)的菜单,每周规则增加“星期”行,固定速率选项只修改它所属单位的间隔数量,一次性规则用独立的“日期”和“时间”两行编辑目标,自定义(cron)用一行“Cron 表达式”编辑按所选时区解释的 5 字段表达式。卡片在浏览器中解析该表达式,并在该行下方显示描述它的本地化语句;Host 会拒绝的表达式改为显示本地化校验信息,并在解析通过前阻止保存。已存储的每周规则若星期集合恰好为周一至周五,则选中“周一至周五”选项。“日期”行是只读触发器,以 `YYYY/MM/DD` 显示暂存的日期并展开带本地化星期表头、月份切换和今天标记的月历;草稿与提交文本保持 ISO 形态;“时间”行是只读触发器,显示统一的 24 小时时钟,并展开时、分、秒三列可滚动列表,当前值高亮并在打开时滚动到可见。在月历或时钟上点选即按与其他行相同的分阶段保存写入草稿;方向键在打开的列或网格内移动,Enter 选定当前值,Escape 关闭面板并把焦点还给所在行,被禁用的行无法打开面板。时钟行显示到秒,未改动的行在提交时保留已存储的毫秒值,编辑时间时按整秒精度提交。“时区”使用高度受限的可搜索菜单。桌面端打开时自动聚焦搜索框;手机和平板需要手动聚焦。顶部无标题区域保留浏览器本地最近选过的 5 个准确时区;首次使用按真实系统时区和 UTC 初始化,而不根据界面语言猜测所在地。系统时区带有标记,其余清单按当前 UTC 偏移、再按规范 IANA 标识排序。可见文案把 `UTC±HH:MM` 放在本地化时区名称之前;IANA 标识仅在内部使用,但可用于搜索。ICU 显示成相同偏移和本地化名称的多个时区只占一行;所有被合并的标识仍可用于搜索,任务已保存的标识也会继续作为选中值,不会被改写。时区清单来自 `Intl.supportedValuesOf('timeZone')`,名称来自 `Intl.DateTimeFormat(..., { timeZoneName: 'longGeneric' })`:两者都是浏览器内置的 ICU/CLDR 数据,不是抓取或产品自维护的翻译表。因此没有需要刷新的爬虫或生成时区资源;第三方语言包只需提供常规的 `time.locale` 值,即可自动获得对应的 ICU 名称,仅需翻译选择器周边的 UI 文案。运行时无法枚举时改用简短的标识候选列表,已存储但不在其中的时区会保留。每天、每周和 cron 保留各自已存储的规则与时区,其频率行仅在时区与宿主系统时区不同时标出时区。一次性 After/At 以记录已存储的时区初始化日期和时间;记录未保存时区时取本设备时区,初始钟表值在该时区下仍表示同一瞬时;更改时区会保留输入的钟表值并改变时点。因选择种类而重新初始化的种类使用已提交发生时点的钟表值和该选项的时区,该时区取记录已存储的时区或本设备时区:每周的星期取自该发生时点在该时区下的星期,cron 则把该发生时点的钟表值写成每天执行的表达式。固定间隔至少为 60 整秒,不受时区影响。一次保存以分阶段的变更提交完整预期记录,并保留 id、原会话、最近一次回执及已保存历史;未来目标选择、重新确定起算时间和未变更规则的无写入语义见 [Schedule](../../schedule/schedule/README.zh.md#use-this-package)。
行在保存进行中、目录加载中、删除期间以及任务未运行时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。
行在保存进行中、目录加载中、删除期间以及任务已结束时禁用。仅当本地草稿与已存储任务不同时才出现保存栏:显示“有未保存的修改”及“取消”和“保存修改”,保存期间“保存修改”变为“保存中…”。“取消”恢复已存储的值。仅当草稿没有未保存修改时,目录刷新才会成为显示值,因此未保存的草稿会保留到权威刷新之后。保存失败保留草稿并显示本地化提示;冲突后针对刷新后的记录重试。任务从目录消失时,“删除”会禁用,直到任务重新出现。更新成功后刷新权威目录。更新被拒绝或无法确认与目录读取失败相互区分:“重试”重新加载目录,不将该更新显示为失败。若更新本身无法确认,应在重试前检查任务。切换任务或关闭详情后才到达的响应不会替换当前视图,任何失败都不会回滚宿主已开始的写入。
打开“任务运行记录”时才请求最新的 20 条已保存记录。“加载更早的记录”按追加顺序从新到旧增加一页,不受实际时间回拨影响。该视图中页签条就是整个页头:名称输入框和“下次运行”行只属于“规则”。每条“已保存的任务运行记录”以时钟图标和任务时区下的本地月份名与时钟字段开头(一次性记录使用浏览器时区),并在存在时显示实际保存的提示文本。超过两行的提示文本只显示前两行并提供“展开”,展开后可“收起”;面板宽度变化时,收起状态的提示文本会重新判断是否超过两行。旧回执没有提示文本时,绝不以当前指令代替。
@@ -41,7 +41,7 @@ kind: "package-reference"
历史查询成功的空页与加载中、查询失败、任务不存在或游标无效相互区分。请求失败时保留此前加载的记录,并显示警告和“重试加载任务运行记录”;游标无效时提供“刷新任务运行记录”,重新加载最新一页。最新 `messageId` 变化时刷新最新一页,不切换页签。被刷新取代的响应,以及切换任务、离开任务运行记录或关闭详情后才收到的响应,不会替换当前视图。
自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以未运行状态保存。显式删除前,通过全部或未运行筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;未运行状态和该回执均不表示模型执行完成。
自动化任务页面要求确认后才能删除任务。删除是硬删除:Host 连同已保存的任务运行记录一起移除存储的任务行,该任务因此停止后续投递、离开 `list` 与 `catalog`,其投递历史不再可读。删除保留原会话和已入队消息;列表仅在权威查询确认移除之前保留该行。每条删除路径——自动化任务页的详情、会话的任务页签、以及页头弹层——都把最终结果写入同一个存储,由应用级 `shell.overlay` 提示渲染,因此提示的生命周期长于发起它的面板或页签:Host 连同已保存的运行记录移除该行后提示显示「任务已删除」,失败时显示「无法删除任务」,并保留规则以便重试。批量清理也由该提示报告:显示 Host 答复的行数「已清理 N 个已结束任务」;Host 答复未移除任何行时显示「没有可清理的已结束任务」;失败时显示「清除任务失败」;普通失败会保留尚未移除的已结束行以便重试,后端报告 `unknown-state` 时可能已移除部分行,并会隔离 Host 的 schedule 领域,直到重新打开。批量清理只关闭自己的确认对话框。确认删除会关闭确认所在的面板或页签,每条提示按自身序号区分,后一条会重新开始横幅而不是并入前一条。查询失败保留最近已知记录,并显示独立的重试操作;不会呈现为查询成功的空结果。一次性任务在持久收件箱投递后仍以已结束状态保存。显式删除前,通过全部或已结束筛选仍可查看详情与原会话 id。最近一次投递显示其发生时间,不显示消息 id 和投递确认时间;已结束状态和该回执均不表示模型执行完成。
已打开的会话只有在确实有可打开的内容时,才在页头右侧功能位中、紧邻更多操作菜单左侧渲染一个仅图标时钟按钮:首次读取前不渲染任何内容,读取结果为没有活动提醒时页头不显示该按钮,刷新期间保留上一次已知的提醒,读取失败则保留按钮及其重试。该规则避免时钟在首次结果前后出现又消失。其无障碍名称在读取就绪时给出提醒数量,否则显示通用提醒标签。当该会话只有一个活动提醒时,按钮直接在右侧栏打开该任务的详情。其余数量都会打开弹层:先列出逾期提醒,再按目标时间列出未来提醒,显示已存储的标题、频率、浏览器本地目标时间、相对时间及删除按钮;标题本身是一个按钮,点击后在右侧栏打开该任务的详情。任务页签在标签上显示当前任务已存储的标题,并提供与自动化任务页面相同的规则、任务运行记录、名称、指令和运行时间修改、确认删除和原会话入口;它不会激活会话。每日频率文本保留保存的本地时间和时区;每个下一目标时点都显示为设备时区的钟点和括号内的相对时长;规则自身的时区只显示在该频率文本中。Escape 关闭弹层并将焦点返回按钮;点击外部将其关闭。删除最后一条提醒会移除按钮;在此之前,已经打开的弹层保持可展开状态。
@@ -49,7 +49,7 @@ kind: "package-reference"
调用 `schedule_create` 时,创建的任务会以卡片形式渲染在 transcript 中,包含时钟图标、已存储的标题、频率,以及打开该任务右侧栏页签的“打开”按钮。卡片先按该调用持久化的结果 JSON 收窄出任务,并从该结果读取 `title`;当一次在卡片出现之后成功结束的目录读取解析到该任务时,卡片改为跟随目录显示当前名称与频率,若该读取中不含该记录,则以“已删除”代替频率显示。在已存储字段存在之前录制的结果回退为指令首行。仍在运行的调用、创建失败,或结果中没有完整任务时,卡片保留该行,并在结果为单个非空文本块时显示原始结果文本,不提供“打开”操作。卡片不增加 Host 字段,也不改变 Session 日志格式,因此早于该卡片录制的 Session 也会渲染它。
侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与未运行 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。
侧栏的 Session 行会标记有定时任务的会话:当某行处于空闲状态、未归档且其会话至少有一个活动任务时,该行显示时钟标记;归档行该格留空,其实时状态只在悬浮卡片中显示。对存在活动任务的会话长按悬停时,无论该行是否空闲,都会在其已有的悬浮卡片内增加自动化区段,最多列出两个任务(图标、已存储的标题、频率与下次运行文本),还有更多任务被隐藏时显示遗漏行。处于更高优先级状态的行——有待处理交互、活动仍在进行,或有未查看的完成提醒——保留自身状态点且不渲染标记,两者不会同时出现。该标记和悬停区段读取共享的活动与已结束 Host 任务目录,并从中筛选本会话的活动任务;它们不会激活、保留或取消归档任何会话,也不读取 Session 日志。
-----
@@ -65,7 +65,7 @@ kind: "package-reference"
<details>
<summary>实现内部细节 — 点击展开</summary>
浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与未运行任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。
浏览器入口注册 `schedules` 主面板及对应的侧栏入口、Session 页头右侧功能位中位于 `order: -5` 且紧邻更多操作菜单左侧的 `schedule-catalog` 入口、经由 `ctx.uiConversation.events` 与 ui-chat 的 list seat `conversation.chat.turnTail` 在轮次层级注册的 `schedule_create` transcript 卡片(因此它渲染在该轮收尾的 assistant 文本之后,并在工具调用组折叠时仍然可见)、root 作用域的两个 Session 行 seat `sidebar.session.row.leading` 与 `sidebar.session.row.hover`,以及 kind 为 `scheduleTask` 的右侧栏页类型 `@deepseek-ai/dsh-client-ui-schedule/task`。该类型分两阶段注册:先把其定义注册进 `ctx.sidebarRightTabs`,再按该定义的 id 把正文与 chip 注册进带键 seat `sidebar.right.pane.tab` 与 `sidebar.right.pane.tab.title`。从 Session 入口打开任务时调用 `ctx.sidebarRight.openTab`,传入该入口的 Session id 与所选任务 id,详情因此出现在该入口所属 Session 的右侧栏中;自动化任务页面保留自己的列表并共用同一个详情组件。全局页面、任务页签、行标记与行悬停区段都从同一个 `schedule.catalog` 源读取活动与已结束任务;页头入口保留自己的按 Session `schedule.list` 读取,只返回该 Session 的活动任务。每个面都使用同一套可观察的查询生命周期,并在 `schedule/changed` 或连接重置后刷新。transcript 卡片从该调用持久化的结果 JSON 收窄出创建的任务,而不是依赖 Host 追加字段;该调用自身的工具调用组单元格由 ui-tool 的通用键控视图渲染,且卡片只对产生完整任务的调用出现。该对齐是因果判定而非时钟比较:目录快照带有 `readRequest`(最新被请求的读取序号)与 `readSettled`(产出当前记录的读取序号);Turn 尾部挂载时锚定 `readRequest` 并请求一次读取,仅在 `readSettled` 大于该锚点后才用其保留记录判定,且源把一次提交内的请求合并为单次 `list()`,只把该读取共享给尚未观察到它的调用者。比较浏览器时钟与 Host 时钟无法确立这一顺序。共用的删除回调与注入的 `onUpdateTiming` 保留所选记录的原 Session 绑定,而不使用当前打开的对话。详情保留当前任务的本地草稿、用于比较的权威值以及等待中或失败的保存;保存等待中或失败期间以及删除确认后,属主保留所显示的任务,使目录刷新在丢弃该行时既不能关闭已呈现的失败,也不能关闭陈述删除的详情。保存发出一次携带完整预期 `ScheduleRecord` 和分阶段变更的 `schedule.update` 请求;`onUpdateTiming` 刷新目录,而不把回读失败变成修改失败。
刷新恢复出的任务标签没有导航参数,因为 Sidebar 只持久化标签的布局记录,不持久化打开方传入的内容。因此该页面类型自行保存从 Session 加布局记录 id 到该记录最后显示任务的 Session 和 id 的关联:每个 Session 一个 `dsh.schedule.task-tab.v1.<sessionId>` localStorage 键,按 tab id 一条记录,且从不保存任务内容。正文和 chip 只对没有导航参数的记录读取该条目,在标签出现之后成功的一次读取无法解析时将其删除,且不代替关联声称任务缺失。这样的一次读取无法为该标签给出任务标识时,正文陈述该状态,而不呈现永不结束的加载。
@@ -1,23 +1,26 @@
/**
* Transient outcome notice for schedule deletions.
*
* Every deletion — the Tasks page detail, the session task tab, and the
* Session-header popover — reports its settled outcome into one store, and one
* `shell.overlay` entry renders it as the app-wide banner. The surfaces keep no
* in-place deletion notice of their own, so an outcome outlives the panel or
* tab that asked for it.
* Every deletion — the Tasks page detail, the session task tab, the
* Session-header popover, and the Tasks page's removal of every ended task —
* reports its settled outcome into one store, and one `shell.overlay` entry
* renders it as the app-wide banner. The surfaces keep no in-place deletion
* notice of their own, so an outcome outlives the panel or tab that asked for
* it.
*/
import type { ReactNode } from 'react'
import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import type { CatalogDeleteOutcome } from './catalog-source.ts'
import type { ClearEndedOutcome } from './clear-ended.ts'
/** One reported deletion outcome; `seq` keys the banner so a re-show restarts it. */
export interface DeleteToastState {
readonly kind: 'deleted' | 'deleteFailed'
readonly seq: number
}
/** One reported outcome; `seq` keys the banner so a re-show restarts it. */
export type DeleteToastState =
| { readonly kind: 'deleted' | 'deleteFailed'; readonly seq: number }
| { readonly kind: 'cleared'; readonly count: number; readonly seq: number }
| { readonly kind: 'clearFailed'; readonly seq: number }
/** Store the deletion wiring reports into and the overlay entry reads. */
export interface DeleteToastSource {
@@ -29,6 +32,11 @@ export interface DeleteToastSource {
* @param outcome - resolution of `CatalogInjected.onDelete`.
*/
readonly report: (outcome: CatalogDeleteOutcome) => void
/**
* Show the notice of one settled bulk removal.
* @param outcome - resolution of the management page's bulk removal callback.
*/
readonly reportClear: (outcome: ClearEndedOutcome) => void
/** Clear the shown notice. */
readonly dismiss: () => void
}
@@ -59,6 +67,11 @@ export function createDeleteToastSource(): DeleteToastSource {
if (outcome === 'pending') return
publish({ kind: outcome === 'deleted' ? 'deleted' : 'deleteFailed', seq: ++seq })
},
reportClear: (outcome) => {
publish(outcome.cleared
? { kind: 'cleared', count: outcome.count, seq: ++seq }
: { kind: 'clearFailed', seq: ++seq })
},
dismiss: () => { publish(null) },
}
}
@@ -66,23 +79,40 @@ export function createDeleteToastSource(): DeleteToastSource {
/** Props of the `shell.overlay` entry: the notice store and localized copy. */
export type ScheduleDeleteToastProps = PropsRuntime<'shell.overlay'>
& PropsLocale<'schedule.manager'>
& InjectFace<Omit<DeleteToastSource, 'report'>>
& InjectFace<Omit<DeleteToastSource, 'report' | 'reportClear'>>
/**
* Render the current deletion notice: a success banner for a confirmed
* deletion, a warning for one that could not be confirmed, or nothing.
* Render the current deletion notice: a success banner for a confirmed deletion
* or settled bulk removal, a warning for one that could not be confirmed, or
* nothing.
* @param props - the notice hook, its dismissal, and the locale seat.
* @returns the banner on display, or null.
*/
export function ScheduleDeleteToast({ useToast, dismiss, t }: ScheduleDeleteToastProps): ReactNode {
const toast = useToast(current => current)
if (toast === null) return null
return toast.kind === 'deleted'
? <Toast key={`schedule-delete-${String(toast.seq)}`} text={t('toast.deleted')} tone="success" onDone={dismiss} />
: <Toast
key={`schedule-delete-${String(toast.seq)}`}
text={t('toast.deleteFailed')}
icon={<IconWarningOutlineRegular />}
onDone={dismiss}
/>
const key = `schedule-delete-${String(toast.seq)}`
switch (toast.kind) {
case 'deleted':
return <Toast key={key} text={t('toast.deleted')} tone="success" onDone={dismiss} />
case 'deleteFailed':
return <Toast key={key} text={t('toast.deleteFailed')} icon={<IconWarningOutlineRegular />} onDone={dismiss} />
case 'cleared':
// A removal that found nothing states that instead of a zero count, and
// without the success tone: the ended rows can leave between the
// confirmation and the request.
return toast.count === 0
? <Toast key={key} text={t('toast.clearedNone')} onDone={dismiss} />
: <Toast
key={key}
text={t(toast.count === 1 ? 'toast.cleared.one' : 'toast.cleared.other', { count: toast.count })}
tone="success"
onDone={dismiss}
/>
case 'clearFailed':
return <Toast key={key} text={t('toast.clearFailed')} icon={<IconWarningOutlineRegular />} onDone={dismiss} />
/* v8 ignore next -- the store publishes only the union's members, so the default holds no reachable statement. */
default:
return assertNever(toast)
}
}
@@ -87,8 +87,9 @@
white-space: nowrap;
}
/* The Plugins toolbar's Add-plugin step: a 32px capsule with the 13px label. */
.newButton {
/* The Plugins toolbar's steps: New and the bulk cleanup share one 32px
capsule with a 13px label. */
.headerAction {
height: 32px;
font-size: 13px;
line-height: 20px;
@@ -148,11 +149,36 @@
neutral hairline, the 10px radius, and the mock's `--muted` leading icon. The
mock brightens the border on hover and takes the brand stroke on focus
rather than drawing a ring around the control. */
.searchField {
.searchTools {
--task-search-height: 36px;
display: flex;
align-items: center;
height: 36px;
margin: 0 0 16px;
gap: 8px;
margin-bottom: 16px;
}
.catalogActions {
flex: none;
}
.catalogMore {
width: var(--task-search-height);
height: var(--task-search-height);
padding: 0;
}
.catalogMore[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchField {
box-sizing: border-box;
display: flex;
flex: 1;
min-width: 0;
align-items: center;
height: var(--task-search-height);
margin: 0;
padding: 0 10px;
border: 0.5px solid var(--dsw-alias-border-l3);
border-radius: 12px;
@@ -1465,6 +1491,9 @@
.deleteButton {
color: var(--dsw-alias-state-error-primary);
/* The outlined confirmation button keeps its border in the danger colour so
the destructive action reads as a bordered control, not bare text. */
--dsh-button-outline-color: var(--dsw-alias-state-error-primary);
}
.page :focus-visible {
@@ -2,7 +2,8 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconPlusOutlineRegular, IconSearchOutlineRegular, Input,
Button, IconClockOutlineRegular, IconCloseOutlineRegular, IconEllipsisOutlineRegular, IconPlusOutlineRegular,
IconSearchOutlineRegular, IconTrashOutlineRegular, Input, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
@@ -25,6 +26,16 @@ export interface TaskManagerInjected extends TaskDetailInjected {
* schedule it. The page deliberately has no creation form of its own.
*/
readonly onNewTask: () => void
/**
* Remove every ended Host task, after this page has confirmed the action.
*
* The call requests no task ids, so the Host's ended set is the one removed;
* the page's rows are only a snapshot that a delivery can have moved. It
* resolves once the settled outcome has been reported through the app-wide
* notice, which is the only place the result is stated.
* @returns resolution after the outcome is reported.
*/
readonly onClearEnded: () => Promise<void>
}
/** Root-scoped task catalog props derived from the framework and injected actions. */
@@ -40,12 +51,15 @@ type StatusFilter = 'all' | ScheduleCatalogEntry['status']
* @returns the searchable task list beside the selected task's detail.
*/
export function TaskManagerPage(props: TaskManagerPageProps) {
const { useCatalog, onNewTask, onRetry, t } = props
const { useCatalog, onNewTask, onClearEnded, onRetry, t } = props
const catalog = useCatalog(snapshot => snapshot)
const { records, status } = catalog
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('active')
const [selectedId, setSelectedId] = useState<ScheduleId | null>(null)
const [clearConfirm, setClearConfirm] = useState(false)
const [clearMenu, setClearMenu] = useState(false)
const [clearing, setClearing] = useState(false)
// The rows state how long remains, so they read the shared ticking clock
// rather than a value sampled at mount: a catalog refresh can move the target
// this text describes.
@@ -54,7 +68,20 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
const { record: catalogRecord, task: selected, id: detailId, confirmId, setConfirmId, setTab } = detail
const rowRef = useRef<HTMLButtonElement | null>(null)
const headingRef = useRef<HTMLHeadingElement>(null)
const clearTriggerRef = useRef<HTMLButtonElement | null>(null)
// Set when the confirmation opens: its settled removal can hide the trigger
// before the Modal returns focus, and so can another client's cleanup.
const clearFocusPending = useRef(false)
// Set when the removal settles: a readback superseded by `schedule/changed`
// can publish its rows after the dialog closed, removing the trigger then.
const clearSettled = useRef(false)
const confirming = records.find(record => record.id === confirmId)
// The bulk action's target and count come from the complete catalog, not from
// the filtered rows: it removes every ended task, whatever the view shows.
const endedCount = useMemo(
() => records.filter(record => record.status === 'inactive').length,
[records],
)
const rows = useMemo(() => {
const query = search.trim().toLowerCase()
return records.filter(record => (
@@ -79,13 +106,15 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
useEffect(() => {
// The detail focuses its own panel; with nothing selected, focus returns
// to the row that opened it, or to the page heading once that row is gone.
if (selectedId !== null) return
// An open confirmation owns the focus until it closes, even when a refresh
// drops the selected task behind it.
if (selectedId !== null || clearConfirm) return
if (rowRef.current !== null) {
const target = rowRef.current.isConnected ? rowRef.current : headingRef.current
target?.focus()
rowRef.current = null
}
}, [selectedId])
}, [selectedId, clearConfirm])
useEffect(() => {
if (status !== 'ready') return
@@ -97,16 +126,55 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
setSelectedId(null)
}
// The Modal restores focus to the element it captured when it opened; the
// trigger can be removed or rebuilt while the confirmation is open, so the
// page points focus at the current control, or the heading when it is gone.
// A settled removal whose readback lands later unmounts the trigger after the
// dialog closed, which drops focus to the document body; that handoff is
// caught by the same effect watching the ended count.
useEffect(() => {
if (clearConfirm) return
if (clearFocusPending.current) {
clearFocusPending.current = false
const trigger = clearTriggerRef.current
if (trigger?.isConnected === true) {
trigger.focus()
return
}
clearSettled.current = false
headingRef.current?.focus()
return
}
if (!clearSettled.current || endedCount > 0 || document.activeElement !== document.body) return
clearSettled.current = false
headingRef.current?.focus()
}, [clearConfirm, endedCount])
const confirmClearEnded = (): void => {
// The confirmation button stays focusable while the removal runs, so a
// second press has to be ignored here instead of by `disabled`.
if (clearing) return
setClearing(true)
void onClearEnded().finally(() => {
clearSettled.current = true
setClearing(false)
setClearConfirm(false)
})
}
return (
<section
className={clsx(css.page, selected !== undefined && css.hasDetails)}
aria-label={t('title')}
data-testid="task-manager-page"
onKeyDown={(event) => {
// A dropdown that closed on this Escape has already consumed the key: the
// Menu primitive closes from the document capture phase and calls
// preventDefault there, before React dispatches this handler.
if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || selectedId === null) return
// An Escape that closed a dropdown is already consumed by the Menu's
// document capture listener. A confirmation's own Escape reaches the
// Modal's document bubble listener, which runs after this React handler,
// so the `clearConfirm` guard is what keeps this page from also closing
// the detail while the dialog closes.
if (event.key !== 'Escape' || event.defaultPrevented || confirmId !== null || clearConfirm
|| selectedId === null) return
event.preventDefault()
event.stopPropagation()
closeDetails()
@@ -118,12 +186,12 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
<div className={css.pageHeading}>
<h1 ref={headingRef} tabIndex={-1}>{t('title')}</h1>
<div className={css.creationActions}>
<Button variant="primary" size="sm" className={css.newButton} icon={<IconPlusOutlineRegular size={13} />} onClick={onNewTask}>{t('new.action')}</Button>
<Button variant="primary" size="sm" className={css.headerAction} icon={<IconPlusOutlineRegular size={13} />} onClick={onNewTask}>{t('new.action')}</Button>
</div>
</div>
<div className={css.filters}>
<div className={css.filterTabs} role="group" aria-label={t('statusFilter.label')}>
{(['all', 'active', 'inactive'] as const).map(value => (
{(['active', 'inactive', 'all'] as const).map(value => (
<button
key={value}
type="button"
@@ -136,18 +204,50 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
))}
</div>
</div>
<div className={css.searchField}>
<Input
type="search"
icon={<IconSearchOutlineRegular />}
aria-label={t('search.label')}
placeholder={t('search.placeholder')}
value={search}
onChange={(event) => { setSearch(event.target.value) }}
/>
{search !== '' && <Button size="sm" className={css.searchClear} aria-label={t('search.clear')} onClick={() => { setSearch('') }}>
<IconCloseOutlineRegular />
</Button>}
<div className={css.searchTools}>
<div className={css.searchField}>
<Input
type="search"
icon={<IconSearchOutlineRegular />}
aria-label={t('search.label')}
placeholder={t('search.placeholder')}
value={search}
onChange={(event) => { setSearch(event.target.value) }}
/>
{search !== '' && <Button size="sm" className={css.searchClear} aria-label={t('search.clear')} onClick={() => { setSearch('') }}>
<IconCloseOutlineRegular />
</Button>}
</div>
{statusFilter !== 'active' && endedCount > 0 && <Menu
open={clearMenu}
onClose={() => { setClearMenu(false) }}
align="end"
portal
items={[{
id: 'clear-inactive', label: t(clearing ? 'clear.pending' : 'clear.action'),
icon: <IconTrashOutlineRegular />, danger: true,
disabled: clearing || status !== 'ready',
}]}
onSelect={() => {
setClearMenu(false)
clearFocusPending.current = true
setClearConfirm(true)
}}
anchor={<Tooltip label={t('list.more')} side="bottom" portal disabled={clearMenu}>
<Button
ref={clearTriggerRef}
variant="outline"
className={css.catalogMore}
aria-label={t('list.more')}
aria-haspopup="menu"
aria-expanded={clearMenu}
aria-busy={clearing}
onClick={() => { setClearMenu(value => !value) }}
>
<IconEllipsisOutlineRegular />
</Button>
</Tooltip>}
/>}
</div>
<div className={css.list}>
{selected === undefined && <CatalogFeedback status={status} populated={rows.length > 0} onRetry={onRetry} t={t} />}
@@ -209,6 +309,31 @@ export function TaskManagerPage(props: TaskManagerPageProps) {
onDeleted={closeDetails}
onClose={closeDetails}
/>}
<Modal
className={css.confirmDialog as string}
contentClassName={css.confirmContent as string}
open={clearConfirm}
title={t('clear.title', { count: endedCount })}
description={t('clear.description')}
closeLabel={t('clear.close')}
// Dismissal waits for the settled removal: the confirmation is the only
// place that states the operation is still running.
onClose={() => { if (!clearing) setClearConfirm(false) }}
footer={<div className={css.confirmActions}>
<Button variant="outline" disabled={clearing} onClick={() => { setClearConfirm(false) }}>
{t('delete.cancel')}
</Button>
<Button
variant="outline"
className={css.deleteButton}
aria-disabled={clearing}
onClick={confirmClearEnded}
>
{t(clearing ? 'clear.pending' : 'clear.confirm')}
</Button>
</div>}
/>
</section>
)
}
@@ -0,0 +1,45 @@
/** Host-wide removal of every ended task, as the management page's one action reports it. */
import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import type { ScheduleDeleteInactiveResult } from '@deepseek-ai/dsh-schedule/client'
/** How one bulk removal settled; the wiring reports it as an app-wide notice. */
export type ClearEndedOutcome =
| { readonly cleared: true; readonly count: number }
| { readonly cleared: false }
/** Remote removal and the authoritative readback that follows it. */
export interface ClearEndedDependencies {
/** Ask the Host to remove every ended task; it answers how many rows it removed. */
readonly removeInactive: () => Promise<RemoteResult<ScheduleDeleteInactiveResult>>
/**
* Read the catalog again, naming the newest request ordinal so a read sent
* before the removal is superseded rather than reused.
* @returns resolution after the read result is published.
*/
readonly refresh: () => Promise<void>
}
/**
* Create the management page's bulk removal callback.
*
* The call names no task ids: the Host owns which rows are ended, and a
* delivery can have moved the catalog between the confirmation and the
* request. The readback follows the acknowledged removal and supersedes a read
* issued before it, so the list follows the removal rather than a stale read.
* @param deps - Remote removal and its catalog readback.
* @returns a callback that resolves once the settled outcome is available.
*/
export function createClearEnded(deps: ClearEndedDependencies): () => Promise<ClearEndedOutcome> {
return async () => {
let result: RemoteResult<ScheduleDeleteInactiveResult>
try {
result = await deps.removeInactive()
} catch (_error: unknown) {
// A rejected request does not establish which rows changed.
return { cleared: false }
}
if (!result.ok) return { cleared: false }
await deps.refresh()
return { cleared: true, count: result.value.deleted }
}
}
@@ -44,6 +44,7 @@ import type {} from '@deepseek-ai/dsh-client-connection/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ScheduleCatalogEntry, ScheduleDeliveryHistoryRequest, ScheduleId, ScheduleUpdateRequest } from '@deepseek-ai/dsh-schedule/client'
import { createCatalogSource, type CatalogDeleteOutcome, type CatalogInjected } from './catalog-source.ts'
import { createClearEnded } from './clear-ended.ts'
import { createDeleteToastSource, ScheduleDeleteToast } from './DeleteToast.tsx'
import { SCHEDULE_TASK_ID, SCHEDULE_TASK_KIND, scheduleTaskDefinition } from './definition.ts'
import { ScheduleCatalogAction } from './ScheduleCatalogAction.tsx'
@@ -145,6 +146,17 @@ export function apply(ctx: ClientContext): void {
ctx.uiWorkspace.openSession(id)
}
}
/**
* Remove every ended Host task and read the catalog back.
*
* The readback names every request seen so far, so it supersedes a read sent
* before the removal instead of joining it.
* @returns the settled bulk-removal outcome.
*/
const clearEnded = createClearEnded({
removeInactive: () => ctx.remote.schedule.deleteInactive(),
refresh: () => manager.onRetry(manager.hooks.catalog.getSnapshot().readRequest),
})
const detail: TaskDetailInjected & ScheduleTaskCatalogInjected = {
hooks: manager.hooks,
onDelete: reportedDelete(manager.onDelete),
@@ -180,6 +192,10 @@ export function apply(ctx: ClientContext): void {
...detail,
// The page has no creation form: a new reminder starts in a Session.
onNewTask: () => { ctx.uiWorkspace.startSession() },
onClearEnded: async () => {
const outcome = await clearEnded()
deleteToast.reportClear(outcome)
},
}),
}, TaskManagerPage))
yield ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
@@ -134,8 +134,20 @@ export const en = {
'delete.cancel': 'Cancel',
'delete.close': 'Close deletion confirmation',
'delete.pending': 'Deleting…',
'list.more': 'More task actions',
'clear.action': 'Clear all inactive tasks',
'clear.title': 'Clear {count} inactive tasks?',
'clear.description': 'These tasks and their saved delivery records will be permanently deleted. '
+ 'Enabled tasks, original sessions, and queued messages are not affected.',
'clear.confirm': 'Clear tasks',
'clear.pending': 'Clearing…',
'clear.close': 'Close clear confirmation',
'toast.deleted': 'Task deleted.',
'toast.deleteFailed': 'Could not delete the task.',
'toast.cleared.one': 'Cleared {count} inactive task.',
'toast.cleared.other': 'Cleared {count} inactive tasks.',
'toast.clearedNone': 'No inactive tasks to clear.',
'toast.clearFailed': 'Could not clear tasks.',
'card.open': 'Open',
'card.openLabel': 'Open task details: {title}',
'card.deleted': 'Deleted',
@@ -276,8 +288,19 @@ export const zh: Record<TaskManagerKey, string> = {
'delete.cancel': '取消',
'delete.close': '关闭删除确认',
'delete.pending': '正在删除…',
'list.more': '更多任务操作',
'clear.action': '清除全部已结束任务',
'clear.title': '清除 {count} 个已结束任务?',
'clear.description': '这些任务及其运行记录将被永久删除,已开启任务、原会话和已排队消息等不受影响。',
'clear.confirm': '确认清除',
'clear.pending': '正在清理…',
'clear.close': '关闭清理确认',
'toast.deleted': '任务已删除',
'toast.deleteFailed': '无法删除任务',
'toast.cleared.one': '已清除 {count} 个已结束任务',
'toast.cleared.other': '已清除 {count} 个已结束任务',
'toast.clearedNone': '没有可清理的已结束任务',
'toast.clearFailed': '清除任务失败',
'card.open': '打开',
'card.openLabel': '打开任务详情:{title}',
'card.deleted': '已删除',
@@ -415,8 +438,19 @@ export const ja = {
'delete.cancel': 'キャンセル',
'delete.close': '削除確認を閉じる',
'delete.pending': '削除中…',
'list.more': 'その他のタスク操作',
'clear.action': '終了済みタスクをすべてクリア',
'clear.title': '終了済みタスク {count} 件をクリアしますか?',
'clear.description': 'これらのタスクと実行履歴は完全に削除されます。実行中のタスク、元のセッション、キューに入ったメッセージなどは影響を受けません。',
'clear.confirm': 'クリアする',
'clear.pending': 'クリア中…',
'clear.close': 'クリア確認を閉じる',
'toast.deleted': 'タスクを削除しました。',
'toast.deleteFailed': 'タスクを削除できませんでした。',
'toast.cleared.one': '終了したタスクを {count} 件クリアしました。',
'toast.cleared.other': '終了したタスクを {count} 件クリアしました。',
'toast.clearedNone': 'クリアする終了済みタスクはありません。',
'toast.clearFailed': 'タスクをクリアできませんでした。',
'card.open': '開く',
'card.openLabel': 'タスクの詳細を開く: {title}',
'card.deleted': '削除済み',
@@ -11,7 +11,9 @@ import { apply as applyNode } from '../src/index.ts'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { ScheduleId, ScheduleCatalogEntry, ScheduleUpdateRequest, ScheduleUpdateResult } from '@deepseek-ai/dsh-schedule/client'
import type {
ScheduleId, ScheduleCatalogEntry, ScheduleDeleteInactiveResult, ScheduleUpdateRequest, ScheduleUpdateResult,
} from '@deepseek-ai/dsh-schedule/client'
import type { RemoteResult } from '@deepseek-ai/dsh-api-remotes/client'
import { RemoteError } from '@deepseek-ai/dsh-client-test-runtime'
import type { CatalogInjected } from '../src/client/catalog-source.ts'
@@ -496,6 +498,83 @@ describe('ui-schedule browser half', () => {
}
})
it('clears every ended task from the global page, reads the catalog back, and reports the count', async () => {
const record: ScheduleCatalogEntry = {
id: 'host-ended' as ScheduleId, sessionId: 'cold-original' as SessionId, status: 'inactive',
kind: 'after', title: 'Ended review', prompt: 'Ended review', afterSeconds: 60,
scheduledAt: '2099-01-01T00:00:00.000Z',
}
const catalog = vi.fn()
.mockResolvedValueOnce({ ok: true, value: [record] })
.mockResolvedValue({ ok: true, value: [] })
const deleteInactive = vi.fn().mockResolvedValue({ ok: true, value: { deleted: 1 } })
const ctx = await baseContext({ schedule: { catalog, deleteInactive }, $on: () => () => {} })
const owner = ctx.slots.register({
name: 'root',
children: {
main: { kind: 'keyed', scope: 'root' },
'shell.overlay': { kind: 'list', scope: 'root' },
},
} as never, Empty)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
try {
const face = injectedFace(ctx.slots.entries('main')[0]!, undefined as never) as TaskManagerInjected
const toast = injectedFace(ctx.slots.entries('shell.overlay')[0]!) as Omit<DeleteToastSource, 'report' | 'reportClear'>
const dispose = face.hooks.catalog.subscribe(vi.fn())
try {
await vi.waitFor(() => { expect(face.hooks.catalog.getSnapshot().records).toEqual([record]) })
const reads = catalog.mock.calls.length
await face.onClearEnded()
expect(deleteInactive).toHaveBeenCalledOnce()
// The acknowledged removal reads the catalog back instead of joining a
// read issued before it, so the page's rows follow the removal.
expect(catalog.mock.calls.length).toBe(reads + 1)
await vi.waitFor(() => { expect(face.hooks.catalog.getSnapshot().records).toEqual([]) })
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 1, seq: 1 })
} finally {
dispose()
}
} finally {
await fiber.dispose()
owner()
await ctx.fiber.dispose()
}
})
it('reports a settled bulk removal after the page registration is gone', async () => {
const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] })
const removal = Promise.withResolvers<{ ok: true; value: ScheduleDeleteInactiveResult }>()
const deleteInactive = vi.fn(() => removal.promise)
const ctx = await baseContext({ schedule: { catalog, deleteInactive }, $on: () => () => {} })
const owner = ctx.slots.register({
name: 'root',
children: {
main: { kind: 'keyed', scope: 'root' },
'shell.overlay': { kind: 'list', scope: 'root' },
},
} as never, Empty)
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
let claimed = false
try {
const face = injectedFace(ctx.slots.entries('main')[0]!, undefined as never) as TaskManagerInjected
const toast = injectedFace(ctx.slots.entries('shell.overlay')[0]!) as Omit<DeleteToastSource, 'report' | 'reportClear'>
const clearing = face.onClearEnded()
// The page panel can unmount while the Host removal is still in flight;
// the notice store belongs to the plugin, so the outcome still publishes.
owner()
claimed = true
removal.resolve({ ok: true, value: { deleted: 2 } })
await clearing
expect(toast.hooks.toast.getSnapshot()).toEqual({ kind: 'cleared', count: 2, seq: 1 })
} finally {
if (!claimed) owner()
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('registers the row mark and hover-card seats under the Session row declarations', async () => {
const catalog = vi.fn().mockResolvedValue({ ok: true, value: [] })
const ctx = await baseContext({ schedule: { catalog }, $on: () => () => {} })
Loaded 100 of 296 files, more files were not shown because too many files have changed in this diff. Show more