Merge feat/creation-recommendations into plugin recommendation experiment
Takes current master through the base, including the runtime-context approval sentence that recorded Web fixtures replay against.
This commit is contained in:
commit
39fa48b074
208 files changed
+2334
-978
No files matched your search
+2
-2
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+2
-2
@@ -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)也使用它们。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
+19
@@ -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.
|
||||
+36
@@ -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`,在该端点上启动路由的测试套件必须自己应答列表请求,才能不访问网络。
|
||||
@@ -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)
|
||||
|
||||
+1
-1
@@ -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"}}}}
|
||||
|
||||
+1
-1
@@ -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"}}}}
|
||||
|
||||
+1
-1
@@ -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"}}}}
|
||||
|
||||
+1
-1
@@ -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"}}}}
|
||||
|
||||
+1
-1
@@ -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"}}}
|
||||
|
||||
+1
-1
@@ -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"}}}
|
||||
|
||||
+1
-1
@@ -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"}}
|
||||
|
||||
+1
-1
@@ -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"}}}}
|
||||
|
||||
+1
-1
@@ -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"}}}
|
||||
|
||||
+1
-1
@@ -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"}}}
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
+7
-17
@@ -1806,25 +1806,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 -->
|
||||
|
||||
@@ -1834,12 +1824,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>
|
||||
}
|
||||
@@ -4386,7 +4376,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. */
|
||||
|
||||
@@ -1808,25 +1808,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 -->
|
||||
|
||||
@@ -1836,12 +1826,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>
|
||||
}
|
||||
@@ -4388,7 +4378,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. */
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
|
||||
@@ -739,7 +739,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
|
||||
@@ -1713,7 +1712,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) |
|
||||
|
||||
@@ -741,7 +741,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
|
||||
@@ -1715,7 +1714,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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 行的扩展位置、显示条件和键盘行为。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 密钥并保存。已安装目录会提供端点、协议和模型列表。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -262,6 +265,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)
|
||||
|
||||
@@ -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,8 +18,8 @@
|
||||
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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
en: 717b86e05b44ebdd
|
||||
zh: 0f4f694d0ea1d7f8
|
||||
/deepseek-ai-dsh-client-ui-sidebar-right/the-guide:
|
||||
en: c67935eddef999fe
|
||||
zh: 0648664ded92ee82
|
||||
en: 5fcda0a3a4453b52
|
||||
zh: d7818c63bf3371f2
|
||||
/deepseek-ai-dsh-client-ui-sidebar-right/copy:
|
||||
en: 4315a1c03ca5fef3
|
||||
zh: c09a09f1d9b99107
|
||||
|
||||
@@ -114,7 +114,7 @@ Close and refresh commands resolve the focused pane and tab occurrence. Page bod
|
||||
|
||||
Each `guide` entry has an `id` unique within its provider. The guide renders `sidebar.right.tab.guide.entry` by the active definition id, forwarding the entry id, resolved title and optional description, plus the framework `useTabInfo` hook. A provider can register a custom card under its definition id; otherwise the standard icon/title button renders. The sidebar owns width and placement, while custom components own their markup and interaction. Guide cards use the shared R20 radius, including hover fills and provider-owned split buttons. Entry identity remains stable across sorting and unrelated registrations. Unboxed shortcut text aligns at the right. The terminal shell menu sits beside its entry title.
|
||||
|
||||
While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps one root list of unpinned card ids, each `kind:entry id`, in browser `localStorage` under `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1`, stored as `{ "unpinned": string[] }`; the key reserves the name of a future guide list slot. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Ids of cards that are not registered stay stored; stored content other than a string array shows every card.
|
||||
While the root-scoped `sidebar.right.tab.guide.actions` list has registrations, a **More tools** card follows the guide cards. Its menu end-aligns to the card, is at least as wide as the card, and grows with its rows up to 480px or the viewport width minus 24px. The menu rows are that list's registrations. Each receives `entries` (cards in registry order with `id`, `label`, and `pinned`; kind `files` is never listed), `onOpen(id)`, which opens the card's tab type in the guide's place, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The guide keeps an in-memory list of unpinned card ids, each `kind:entry id`. Plugin reload resets pin choices; legacy localStorage values are ignored. The default More menu only offers plugin browsing and creation; pin callbacks remain available to other registrations. The kind identifies a card instead of the provider id, so a pin choice survives an extension taking over that kind with the same entry id. Cards of kind `files` always show. Unpinning hides only the card; it closes no tab and changes neither tab-type registration nor the default page. Description density follows the visible card count. Unregistered card ids remain in memory until plugin reload.
|
||||
|
||||
A tab type can declare `multiple: true` for independent instances such as terminals. Each open receives a distinct content address, so placement and docking preserve those instances; ordinary pages retain per-pane uniqueness.
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ Tab 域按(Session,Tab id)保留品牌化 occurrence id、导航、中止
|
||||
|
||||
每个 `guide` 入口具有 provider 内唯一的 `id`。开始页按当前生效的定义 id 渲染 `sidebar.right.tab.guide.entry`,传入入口 id、解析后的标题、可选描述,以及框架提供的 `useTabInfo` hook。provider 可按自己的定义 id 注册自定义卡片;未注册时使用默认图标与标题按钮。侧栏负责宽度和排列,自定义组件负责内部结构和交互。引导卡片统一使用 R20,包含悬停底色与提供方自有的分体按钮。排序或其他注册变化不会改变入口身份。无背景的快捷键文字右对齐。终端 Shell 菜单位于入口标题旁。
|
||||
|
||||
root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.right.tab.guide.list.v1` 下保存一份 root 级的已收起卡片 id 列表,每个 id 为 `kind:入口 id`,格式为 `{ "unpinned": string[] }`;该键名为以后的引导入口 list slot 预留。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在列表中;保存内容不是字符串数组时,所有卡片都显示。
|
||||
root 作用域的 `sidebar.right.tab.guide.actions` list 有注册项时,引导卡片之后显示**更多工具**卡片。它的菜单与卡片末端对齐,不窄于卡片,并随菜单行增宽,最大为 480px 与视口宽度减 24px 中的较小值。菜单行为该 list 的注册项。每个注册项接收 `entries`(按注册表顺序的卡片,含 `id`、`label` 与 `pinned`;不含 kind 为 `files` 的卡片)、在引导页位置打开卡片标签类型的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。引导页在内存中保存已收起卡片 id 列表,每个 id 为 `kind:入口 id`。插件重新加载时 Pin 选择重置,不读取旧 localStorage 值。默认更多菜单只提供插件浏览和创建;其他注册项仍可使用 Pin 回调。卡片由 kind 而不是提供方 id 标识,因此扩展接管某个 kind 且保持入口 id 时,Pin 选择仍然有效。kind 为 `files` 的卡片始终显示。收起只隐藏卡片,不关闭标签,也不改变标签类型注册或默认页。描述的显示密度按可见卡片数计算。未注册卡片的 id 保留在内存中,直到插件重新加载。
|
||||
|
||||
终端等需要独立实例的 tab 类型可声明 `multiple: true`。每次打开都会获得独立内容地址,因此移动和停靠会保留这些实例;普通页面仍在每个格内去重。
|
||||
|
||||
|
||||
@@ -19,8 +19,7 @@
|
||||
* open.
|
||||
*/
|
||||
import { useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconAppsAddOutlineRegular, Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { Menu, ShortcutKeys } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
@@ -30,6 +29,7 @@ import type {
|
||||
import type { SidebarRightGuideActionsOwnerProps } from '../../contract/slots.ts'
|
||||
import type { SidebarRightGuideBox } from '../../tab-registry.ts'
|
||||
import { CompassGlyph, CubeGlyph } from './GuideTitle.tsx'
|
||||
import { GuideMoreIcon } from './GuideMoreIcon.tsx'
|
||||
import { FIXED_GUIDE_KIND, guideEntryId } from './guide-pins.ts'
|
||||
import css from './GuideBody.module.css'
|
||||
|
||||
@@ -39,7 +39,7 @@ export interface GuideInjected {
|
||||
readonly hooks: {
|
||||
readonly shortcuts: ObservableSnapshot<readonly ShortcutCatalogEntry[]>
|
||||
readonly guideEntries: ObservableSnapshot<readonly SidebarRightGuideBox[]>
|
||||
/** Unpinned `kind:id` card ids, persisted across reloads. */
|
||||
/** Unpinned `kind:id` card ids held in memory until plugin reload. */
|
||||
readonly entryPins: ObservableSnapshot<readonly string[]>
|
||||
/** Whether `sidebar.right.tab.guide.actions` has registrations. */
|
||||
readonly entryActions: ObservableSnapshot<boolean>
|
||||
@@ -106,7 +106,7 @@ function GuideMoreMenu({ label, entries, onOpen, onPinnedChange, renderSlot }: G
|
||||
anchor={(
|
||||
<button type="button" className={css.entry} aria-label={label} aria-haspopup="menu" aria-expanded={open}
|
||||
onClick={() => { setOpen(value => !value) }}>
|
||||
<span className={clsx(css.entryIcon, css.placeholderInk)}><IconAppsAddOutlineRegular size={22} compact /></span>
|
||||
<span className={css.entryIcon}><GuideMoreIcon /></span>
|
||||
<span className={css.entryText}><span className={css.entryTitle}>{label}</span></span>
|
||||
</button>
|
||||
)}>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Designer-supplied artwork used only by the right-sidebar More tools card. */
|
||||
|
||||
/**
|
||||
* Render the More tools glyph at its authored size and ink.
|
||||
* @returns Decorative 22px More tools artwork.
|
||||
*/
|
||||
export function GuideMoreIcon() {
|
||||
return <svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg"
|
||||
stroke="#A6B2C7" strokeWidth="1.38181" aria-hidden="true" focusable="false">
|
||||
<path d="M5.45873 5.04102H7.90157C8.57615 5.04102 9.123 5.58787 9.123 6.26244V8.70528C9.123 9.37986 8.57615 9.92671 7.90157 9.92671H5.45873C4.78415 9.92671 4.2373 9.37986 4.2373 8.70528V6.26244C4.2373 5.58787 4.78415 5.04102 5.45873 5.04102Z" />
|
||||
<path d="M5.45873 12.9805H7.90157C8.57615 12.9805 9.123 13.5273 9.123 14.2019V16.6447C9.123 17.3193 8.57615 17.8662 7.90157 17.8662H5.45873C4.78415 17.8662 4.2373 17.3193 4.2373 16.6447V14.2019C4.2373 13.5273 4.78415 12.9805 5.45873 12.9805Z" />
|
||||
<path d="M13.3992 12.9805H15.842C16.5166 12.9805 17.0634 13.5273 17.0634 14.2019V16.6447C17.0634 17.3193 16.5166 17.8662 15.842 17.8662H13.3992C12.7246 17.8662 12.1777 17.3193 12.1777 16.6447V14.2019C12.1777 13.5273 12.7246 12.9805 13.3992 12.9805Z" />
|
||||
<path d="M15.0692 3.86523V6.91879M15.0692 6.91879V9.97235M15.0692 6.91879H12.0156M15.0692 6.91879H18.1227" />
|
||||
</svg>
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
/** Persisted pin choices for the guide's tool cards. */
|
||||
/** In-memory pin choices for the guide's tool cards. */
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
import type { SidebarRightGuideBox } from '../../tab-registry.ts'
|
||||
|
||||
/** Tab kind whose card always shows and never enters the customization list. */
|
||||
export const FIXED_GUIDE_KIND = 'files'
|
||||
|
||||
/** Root-level key reserved for the future guide list slot's per-list preferences. */
|
||||
const STORAGE_KEY = 'dsh.slot.manual-order.sidebar.right.tab.guide.list.v1'
|
||||
const NO_CARDS: readonly string[] = []
|
||||
|
||||
/**
|
||||
@@ -23,30 +21,23 @@ export interface GuidePins {
|
||||
/** Unpinned `kind:id` card ids; ids of cards that are not registered stay listed. */
|
||||
readonly unpinned: ObservableSnapshot<readonly string[]>
|
||||
/**
|
||||
* Show or hide one guide card; cards of the fixed kind are ignored.
|
||||
* Pin or unpin one guide card; cards of the fixed kind are ignored.
|
||||
* @param id - `kind:id` card id.
|
||||
* @param pinned - Whether the card shows on the guide.
|
||||
* @param pinned - Whether the card is manually pinned.
|
||||
*/
|
||||
readonly setPinned: (id: string, pinned: boolean) => void
|
||||
}
|
||||
|
||||
/** Stored content is unchecked; anything but a string array leaves every card pinned. */
|
||||
function unpinnedCards(value: unknown): readonly string[] {
|
||||
if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_CARDS
|
||||
const { unpinned } = value
|
||||
return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_CARDS
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the persisted guide pin list; the snapshot keeps its reference until the list changes.
|
||||
* @returns Validated unpinned card ids and the pin mutation.
|
||||
* Create the in-memory guide pin list; the snapshot keeps its reference until the list changes.
|
||||
* @returns Unpinned card ids and the pin mutation.
|
||||
*/
|
||||
export function createGuidePins(): GuidePins {
|
||||
const store = createSnapshotStore<unknown>({ unpinned: NO_CARDS }, { persist: { name: STORAGE_KEY } })
|
||||
const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_CARDS })
|
||||
return {
|
||||
unpinned: { getSnapshot: () => unpinnedCards(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
|
||||
unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) },
|
||||
setPinned: (id, pinned) => {
|
||||
const current = unpinnedCards(store.getSnapshot())
|
||||
const current = store.getSnapshot().unpinned
|
||||
if (id.startsWith(`${FIXED_GUIDE_KIND}:`) || pinned !== current.includes(id)) return
|
||||
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
|
||||
},
|
||||
|
||||
@@ -119,6 +119,12 @@ describe('GuideBody', () => {
|
||||
const guide = b.view.container.querySelector('[data-sidebar-right-guide]')
|
||||
expect(guide?.lastElementChild?.contains(more)).toBe(true)
|
||||
expect(more.getAttribute('aria-haspopup')).toBe('menu')
|
||||
const glyph = more.querySelector('svg')
|
||||
expect(glyph?.getAttribute('width')).toBe('22')
|
||||
expect(glyph?.getAttribute('height')).toBe('22')
|
||||
expect(glyph?.getAttribute('stroke')).toBe('#A6B2C7')
|
||||
expect(glyph?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(glyph?.querySelectorAll('path')).toHaveLength(4)
|
||||
fireEvent.click(more)
|
||||
expect(more.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(b.actionsOwner().entries).toEqual([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Guide card pins persist per `kind:id` and ignore every Files card. */
|
||||
/** Guide card pins stay in memory per `kind:id` and ignore every Files card. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createGuidePins, guideEntryId } from '../src/client/tabs/guide/guide-pins.ts'
|
||||
|
||||
@@ -11,15 +11,15 @@ describe('guide card pins', () => {
|
||||
expect(guideEntryId({ kind: 'terminal', id: 'shell' })).toBe('terminal:shell')
|
||||
})
|
||||
|
||||
it('persists unpinned card ids and restores them in a new instance', () => {
|
||||
it('keeps unpinned card ids in memory without restoring them in a new instance', () => {
|
||||
const pins = createGuidePins()
|
||||
const initial = pins.unpinned.getSnapshot()
|
||||
expect(initial).toEqual([])
|
||||
expect(pins.unpinned.getSnapshot()).toBe(initial)
|
||||
pins.setPinned('terminal:shell', false)
|
||||
pins.setPinned('browser:page', false)
|
||||
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['terminal:shell', 'browser:page'] })
|
||||
expect(createGuidePins().unpinned.getSnapshot()).toEqual(['terminal:shell', 'browser:page'])
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
expect(createGuidePins().unpinned.getSnapshot()).toEqual([])
|
||||
pins.setPinned('terminal:shell', true)
|
||||
expect(pins.unpinned.getSnapshot()).toEqual(['browser:page'])
|
||||
})
|
||||
@@ -38,20 +38,14 @@ describe('guide card pins', () => {
|
||||
})
|
||||
|
||||
it('keeps ids of cards that are not registered', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed:card'] }))
|
||||
const pins = createGuidePins()
|
||||
pins.setPinned('removed:card', false)
|
||||
pins.setPinned('terminal:shell', false)
|
||||
expect(pins.unpinned.getSnapshot()).toEqual(['removed:card', 'terminal:shell'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-array list', { unpinned: 'terminal:shell' }],
|
||||
['non-string ids', { unpinned: [false] }],
|
||||
['a missing list', {}],
|
||||
['null', null],
|
||||
['a number', 7],
|
||||
])('reads %s as no unpinned cards 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: ['terminal:shell'] }))
|
||||
const pins = createGuidePins()
|
||||
const snapshot = pins.unpinned.getSnapshot()
|
||||
expect(snapshot).toEqual([])
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
en: a970f0547496634f
|
||||
zh: 36ef9a85b26e19f1
|
||||
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/brand-and-new-session:
|
||||
en: f2c2254325cea5db
|
||||
zh: c9ef643b431f8328
|
||||
en: 44db2d448bc2ea6d
|
||||
zh: cbb1143afb524d5b
|
||||
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/global-panel-entries:
|
||||
en: b109498d88367117
|
||||
zh: 6d407f265bacdb0d
|
||||
en: e7f316023445511b
|
||||
zh: 6271c8c011533877
|
||||
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/collapse-behavior:
|
||||
en: 20e744360b4be2cc
|
||||
zh: a90be7e8526bbb21
|
||||
@@ -33,11 +33,11 @@
|
||||
en: 9e7b31acee759f2b
|
||||
zh: 3a6beb63b23d26ae
|
||||
/deepseek-ai-dsh-client-ui-sidebar/use-this-package/click-analytics:
|
||||
en: bb3f4a199ed49039
|
||||
zh: cc29413fd31fe2e7
|
||||
en: 8855ad013285a27e
|
||||
zh: f19b3e75c38c5b0a
|
||||
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation:
|
||||
en: bc51c446d8b8e098
|
||||
zh: c0c7bbfb50541d33
|
||||
en: 39b5e3ca8ce8c5c2
|
||||
zh: 1d652b7554772dad
|
||||
/deepseek-ai-dsh-client-ui-sidebar/understand-the-implementation/slot-discipline:
|
||||
en: 6eefc5c45abe8a8f
|
||||
zh: 20b651e48a0ea903
|
||||
|
||||
@@ -31,13 +31,13 @@ The sidebar is the navigation shell: users see the brand, start new sessions, co
|
||||
|
||||
### Brand and New Session
|
||||
|
||||
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. On Web and Windows desktop, clicking the expanded brand starts a new session; hovering or focusing it shows no tooltip. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. Clicking the fallback mark or label of such a build copies that badge text instead of starting a session, also on macOS desktop, and a successful write shows a Version copied toast. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. The expanded New Session button displays the effective binding as trailing grey text on keyboard focus regardless of hover capability, and on hover when the device supports it. While the shortcut is visible, the centered icon and label fade before it when space is narrow; CSS reserves the shortcut’s width without measuring the button. Icon-only controls retain tooltips with platform-formatted keycaps, including the resident macOS header control when the sidebar is hidden. All controls expose `aria-keyshortcuts`.
|
||||
The expanded brand row renders `sidebar.brand.mark` and `sidebar.brand.name` as independent single slots; the collapsed rail renders the same mark slot. On Web and Windows desktop, clicking the expanded brand starts a new session; hovering or focusing it shows no tooltip. Without occupants, the shell uses the fish mark and a localized local-build label. A complete build stacks a code badge below the label as `version[-commit][-dirty]`, using `DSH_CLIENT_VERSION`, the optional 7-character `DSH_CLIENT_COMMIT_HASH`, and `DSH_CLIENT_GIT_DIRTY=true`; missing version metadata omits the badge. Clicking the fallback mark or label of such a build copies that badge text instead of starting a session, also on macOS desktop, and a successful write shows a Version copied toast. New Session targets the explicit Workspace used by a scoped action, otherwise the current Session's Workspace, otherwise the most recently active Workspace; when none exists it clears into the blank New Session page. The expanded New Session button displays the effective binding as trailing grey text on keyboard focus regardless of hover capability, and on hover when the device supports it. While the shortcut is visible, the centered icon and label fade before it when space is narrow; CSS reserves the shortcut’s width without measuring the button. Icon-only controls retain tooltips with platform-formatted keycaps, including the resident macOS header control when the sidebar is hidden. All controls expose `aria-keyshortcuts`. While the global access snapshot excludes `session/create`, the brand row stays inert and neither the New Session button nor the window-chrome New Session control renders; the Host still decides every create request.
|
||||
|
||||
### Global panel entries
|
||||
|
||||
Plugins add an icon component to the root-scoped `sidebar.panellist` list with an `id`, optional `order`, and a string or locale-aware `label`. The same id addresses the component registered in the layout's root-scoped `main` keyed slot; selecting a missing main entry throws without changing the current selection. The label supplies plain visible text, the accessible name, and the collapsed tooltip. Expanded navigation rows have a 2px gap. Each row reads its own selected state through `usePanelInfo`; moving DOM focus to search or a directory picker does not change the displayed panel or its selected row. With no panel registrations, neither the list nor spacing for it is rendered. The shipped composition registers no example panel.
|
||||
|
||||
While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, and `pinned`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one root list of unpinned panel ids in browser `localStorage` under `dsh.slot.manual-order.sidebar.panellist.v1`, stored as `{ "unpinned": string[] }` and shared by every Session; the rows omit unpinned panels except the selected one, which keeps its row and `aria-current` until another panel is selected, and Plugins always shows. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Ids of panels that are not registered stay stored, so a panel that registers again stays unpinned; stored content other than a string array shows every row.
|
||||
While the root-scoped `sidebar.panellist.actions` list has registrations, a **More** button styled as a panel row follows the rows inside the navigation landmark; the collapsed rail shows its label as a tooltip. It opens a menu whose rows are that list's registrations. Each receives `entries` (customizable panels in registration order with `id`, `label`, `pinned`, and `active`; Plugins is never listed), `onOpen(id)`, which selects the panel, `onPinnedChange(id, pinned)`, and `onDismiss()`, which closes the menu. The sidebar keeps one in-memory list of unpinned panel ids shared by every Session; Plugins always shows. The default More menu only offers plugin browsing and creation, without panel rows or pin controls. Pin callbacks remain available to other registrations, but choices reset when the plugin reloads and legacy localStorage values are ignored. Unpinning hides only the row; the main panel, its registration, and the current selection stay unchanged. Unregistered panel ids remain in the in-memory list until the plugin reloads.
|
||||
|
||||
### Collapse behavior
|
||||
|
||||
@@ -64,7 +64,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
|
||||
- The expanded brand and the New Session button report `menu_name=new_session` with `position=sidebar`. While the Windows caption hosts the collapsed New Session button, that button reports `position=titlebar`; the macOS New Session control beside the traffic lights also reports `position=titlebar`.
|
||||
- The **Plugins** and **Automation tasks** rows report `menu_name=plugin` and `menu_name=cron` with `position=sidebar`. Other panel rows report nothing.
|
||||
- Each **More** button click reports `menu_name=more` with `position=sidebar`, whether it opens or closes the menu.
|
||||
- A More menu row that opens Plugins or Automation tasks through `onOpen(id)` reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected.
|
||||
- A third-party More menu row that calls `onOpen(id)` for Plugins or Automation tasks reports `menu_name=plugin` or `menu_name=cron` with `position=sidebar_more`. The shipped More menu only browses and creates plugins, so it never calls `onOpen`. The report precedes the registration check, so a request for a panel that has unregistered still counts although nothing is selected.
|
||||
|
||||
-----
|
||||
|
||||
@@ -74,7 +74,7 @@ On Desktop, the official navigation controls report `sidebar_menu_click` with `m
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. Panel entries and their optional titles use the same composition path. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
|
||||
The shell is pure composition: `SidebarRootComponentProps` composes the layout owner share, the global `useSessions` and `useWorkspaces` hooks, the declared brand, the `sidebar.workspaces` and `sidebar.settings` child slots, and injected navigation callbacks. The sidebar owns `sidebar.panellist` and renders each panel's registered icon directly. Panel metadata is derived from list registrations and locale changes; selection belongs to the layout store. The injected `trackEntryClick(attributes)` forwards to `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`, and `selectPanel` only selects, so each control supplies its own entry and position.
|
||||
|
||||
### Slot discipline
|
||||
|
||||
|
||||
@@ -31,13 +31,13 @@ dsh Web 客户端的侧边栏让用户识别当前构建、启动新会话、将
|
||||
|
||||
### 品牌与 New Session
|
||||
|
||||
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。在 Web 和 Windows 桌面端,点击展开态的品牌会新建会话,悬停或聚焦时不显示 tooltip。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。此类构建中点击回退的标记或标签会复制该徽标文本而非新建会话,macOS 桌面端同样适用;写入成功后弹出“版本号复制成功”toast。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 展开态的新建会话按钮在键盘聚焦时于右侧以灰色文字显示当前有效绑定,不受设备悬停能力限制;支持悬停的设备也会在悬停时显示。快捷键可见且空间较窄时,居中的图标与文字在快捷键之前渐隐;CSS 为快捷键保留宽度,无需测量按钮。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。
|
||||
展开的品牌行把 `sidebar.brand.mark` 与 `sidebar.brand.name` 渲染为两个独立的 single slot;收起轨道则渲染同一个 mark slot。在 Web 和 Windows 桌面端,点击展开态的品牌会新建会话,悬停或聚焦时不显示 tooltip。没有占位者时,外壳使用鱼形标记和本地化的本地构建标签。完整构建会在标签下方显示代码徽标;该徽标使用 `DSH_CLIENT_VERSION`、可选的 7 位 `DSH_CLIENT_COMMIT_HASH` 与 `DSH_CLIENT_GIT_DIRTY=true` 组装成 `version[-commit][-dirty]`;缺少版本元数据时不显示徽标。此类构建中点击回退的标记或标签会复制该徽标文本而非新建会话,macOS 桌面端同样适用;写入成功后弹出“版本号复制成功”toast。New Session 优先使用作用域操作明确指定的 Workspace,否则使用当前 Session 所属 Workspace,再否则使用最近活跃 Workspace;一个 Workspace 都没有时则清空选择,进入空白 New Session 页面。 展开态的新建会话按钮在键盘聚焦时于右侧以灰色文字显示当前有效绑定,不受设备悬停能力限制;支持悬停的设备也会在悬停时显示。快捷键可见且空间较窄时,居中的图标与文字在快捷键之前渐隐;CSS 为快捷键保留宽度,无需测量按钮。纯图标控件保留使用平台键位样式的 tooltip,侧栏隐藏时常驻的 macOS 头部控件也保持一致。所有控件均提供 `aria-keyshortcuts`。全局访问快照不含 `session/create` 时,品牌行不可点击,New Session 按钮和窗口标题栏中的 New Session 控件都不渲染;每个新建请求仍由 Host 判定。
|
||||
|
||||
### 全局面板入口
|
||||
|
||||
插件在 root 作用域的 `sidebar.panellist` list 中注册图标组件,提供 `id`、可选 `order`,以及字符串或 locale-aware 的 `label`。同一个 id 寻址布局中 root 作用域 `main` keyed slot 的组件;选择不存在的主面板条目会抛错,并保留当前选中态。标签提供普通可见文字、无障碍名称和折叠提示。展开态导航行的间距为 2px。每一行通过 `usePanelInfo` 读取自己的选中态;DOM 焦点移到搜索框或目录选择器时,显示的面板及其列表项选中态不变。没有面板注册项时,列表及其间距均不渲染。产品随附的组合不注册示例面板。
|
||||
|
||||
root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label` 与 `pinned`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在浏览器 `localStorage` 的 `dsh.slot.manual-order.sidebar.panellist.v1` 下保存一份 root 级的已收起面板 id 列表,格式为 `{ "unpinned": string[] }`,所有会话共用;面板行不显示已收起的面板,但当前选中的面板保留其行和 `aria-current`,直到选中其他面板;插件管理入口始终显示。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在列表中,因此重新注册的面板保持收起;保存内容不是字符串数组时,所有行都显示。
|
||||
root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区域的面板行之后显示面板行样式的**更多**按钮;收起的窄栏以提示框显示其名称。它打开的菜单以该 list 的注册项为菜单行。每个注册项接收 `entries`(按注册顺序的可自定义面板,含 `id`、`label`、`pinned` 与 `active`;不含插件管理)、选中面板的 `onOpen(id)`、`onPinnedChange(id, pinned)`,以及关闭菜单的 `onDismiss()`。侧边栏在内存中保存所有会话共用的已收起面板 id 列表;插件管理入口始终显示。默认更多菜单只提供插件浏览和创建,不列出面板或 Pin 控件。其他注册项仍可调用 Pin 回调,但插件重新加载时选择重置,不读取旧 localStorage 值。收起只隐藏导航行,主面板、其注册和当前选中态不变。未注册面板的 id 保留在内存列表中,直到插件重新加载。
|
||||
|
||||
### 折叠行为
|
||||
|
||||
@@ -64,7 +64,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
|
||||
- 展开态品牌与新建会话按钮上报 `menu_name=new_session`、`position=sidebar`。Windows 顶栏承载收起态新建会话按钮时,该按钮上报 `position=titlebar`;macOS 红绿灯旁的新建会话控件同样上报 `position=titlebar`。
|
||||
- **插件**与**自动化任务**面板行分别上报 `menu_name=plugin` 与 `menu_name=cron`,`position=sidebar`。其他面板行不上报。
|
||||
- 每次点击**更多**按钮都上报 `menu_name=more`、`position=sidebar`,无论打开还是关闭菜单。
|
||||
- 更多菜单中的行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。
|
||||
- 第三方注册的更多菜单行通过 `onOpen(id)` 打开插件或自动化任务时,上报 `menu_name=plugin` 或 `menu_name=cron`,`position=sidebar_more`。随产品提供的更多菜单只浏览和创建插件,不会调用 `onOpen`。上报先于注册检查,因此所请求的面板已注销时仍会计数,但不会选中任何面板。
|
||||
|
||||
-----
|
||||
|
||||
@@ -74,7 +74,7 @@ root 作用域的 `sidebar.panellist.actions` list 有注册项时,导航区
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。面板入口及其可选标题使用相同的组合方式。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
|
||||
外壳是纯组合:`SidebarRootComponentProps` 组合布局 owner share、全局 `useSessions` 与 `useWorkspaces` 钩子、已声明的品牌、`sidebar.workspaces` 与 `sidebar.settings` 子 slot,以及注入的导航回调。侧边栏声明 `sidebar.panellist`,并直接渲染各面板注册的图标。面板元数据由列表注册和 locale 变化派生;选中态属于布局存储。注入的 `trackEntryClick(attributes)` 转发到 `ctx.get('productAnalytics')?.track('sidebar_menu_click', attributes)`;`selectPanel` 只负责选中,因此由各控件自行提供入口与位置。
|
||||
|
||||
### slot 纪律
|
||||
|
||||
|
||||
@@ -20,10 +20,14 @@ export type HeaderLeadingControlsProps =
|
||||
* controls off screen; this occupant puts them back beside the traffic
|
||||
* lights. The frame mounts the seat only in that state and owns its
|
||||
* placement, so the occupant renders unconditionally.
|
||||
* @param props - Injected sidebar actions plus the sidebar locale seat.
|
||||
* @returns the two window-chrome controls.
|
||||
* @param props - Injected sidebar actions, the global access Hook, and the sidebar locale seat.
|
||||
* @returns the sidebar toggle, plus the New Session control while the access snapshot allows `session/create`.
|
||||
*/
|
||||
export function HeaderLeadingControls({ toggleSidebar, startSession, trackEntryClick, useShortcuts, t }: HeaderLeadingControlsProps) {
|
||||
export function HeaderLeadingControls({
|
||||
toggleSidebar, startSession, trackEntryClick, useRemoteAccess, useShortcuts, t,
|
||||
}: HeaderLeadingControlsProps) {
|
||||
// A page whose access snapshot excludes starting a Session keeps only the sidebar toggle here.
|
||||
const canStartSession = useRemoteAccess('session/create')
|
||||
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle'))
|
||||
const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new'))
|
||||
return (
|
||||
@@ -39,20 +43,22 @@ export function HeaderLeadingControls({ toggleSidebar, startSession, trackEntryC
|
||||
<IconPanelLeftOutlineRegular size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('session.new.label')}
|
||||
aria-keyshortcuts={newShortcut?.aria}
|
||||
onClick={() => {
|
||||
trackEntryClick({ menu_name: 'new_session', position: 'titlebar' })
|
||||
startSession()
|
||||
}}
|
||||
>
|
||||
<IconNewChatOutlineRegular size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{canStartSession && (
|
||||
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-label={t('session.new.label')}
|
||||
aria-keyshortcuts={newShortcut?.aria}
|
||||
onClick={() => {
|
||||
trackEntryClick({ menu_name: 'new_session', position: 'titlebar' })
|
||||
startSession()
|
||||
}}
|
||||
>
|
||||
<IconNewChatOutlineRegular size={16} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -135,6 +135,7 @@ export function SidebarRoot({
|
||||
selectPanel,
|
||||
setPinned,
|
||||
trackEntryClick,
|
||||
useRemoteAccess,
|
||||
usePanels,
|
||||
useEntryPins,
|
||||
useEntryActions,
|
||||
@@ -149,6 +150,8 @@ export function SidebarRoot({
|
||||
const hasEntryActions = useEntryActions(registered => registered)
|
||||
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'sidebar.left.toggle'))
|
||||
const newShortcut = useShortcuts(rows => rows.find(row => row.id === 'session.new'))
|
||||
// A page whose access snapshot excludes starting a Session offers no New Session affordance.
|
||||
const canStartSession = useRemoteAccess('session/create')
|
||||
const toggleLabel = collapsed ? t('toggle.open') : t('toggle.collapse')
|
||||
// Wide content stays mounted while the collapse animates (fading via
|
||||
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
|
||||
@@ -278,7 +281,8 @@ export function SidebarRoot({
|
||||
traffic lights and keeps the toggle at the sidebar's top-right. */}
|
||||
{darwinDesktop && <div className={css.topStrip} data-window-drag>{toggle}</div>}
|
||||
<div className={css.logoRow} data-window-drag>
|
||||
{/* Expanded, the brand doubles as a New Session shortcut — except on
|
||||
{/* Expanded, the brand doubles as a New Session shortcut while the
|
||||
access snapshot allows `session/create` — except on
|
||||
macOS, where it stays part of the logo row's window-drag surface
|
||||
(a button would subtract itself through the global no-drag rule);
|
||||
the collapsed rail's logo is the expand toggle below instead.
|
||||
@@ -307,7 +311,7 @@ export function SidebarRoot({
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
return darwinDesktop
|
||||
return darwinDesktop || !canStartSession
|
||||
? <span className={clsx(css.brand, css.wide)}>{identity}</span>
|
||||
: (
|
||||
<button
|
||||
@@ -328,29 +332,31 @@ export function SidebarRoot({
|
||||
</div>
|
||||
|
||||
{/* The label fades before the hover/focus shortcut, including on translucent backgrounds. */}
|
||||
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500} side={captionTooltipSide} disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label={t('session.new.label')}
|
||||
aria-keyshortcuts={newShortcut?.aria}
|
||||
onClick={openNewSession}
|
||||
>
|
||||
<span className={css.newSessionLabelMask}><span className={css.newSessionContent}>
|
||||
{wide
|
||||
? <IconNewChatOutlineMedium size={14} />
|
||||
: <IconNewChatOutlineRegular size={windowsTitlebar ? 16 : 18} />}
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
|
||||
</span></span>
|
||||
{wide && newShortcut !== undefined && newShortcut.keys.length > 0 && <span className={css.newSessionShortcut} aria-hidden="true">
|
||||
<ShortcutKeys keys={newShortcut.keys} />
|
||||
</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{canStartSession && (
|
||||
<Tooltip label={t('session.new.label')} shortcutKeys={newShortcut?.keys} delayMs={500} side={captionTooltipSide} disabled={wide}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.newSession}
|
||||
aria-label={t('session.new.label')}
|
||||
aria-keyshortcuts={newShortcut?.aria}
|
||||
onClick={openNewSession}
|
||||
>
|
||||
<span className={css.newSessionLabelMask}><span className={css.newSessionContent}>
|
||||
{wide
|
||||
? <IconNewChatOutlineMedium size={14} />
|
||||
: <IconNewChatOutlineRegular size={windowsTitlebar ? 16 : 18} />}
|
||||
{wide && <span className={clsx(css.newSessionLabel, css.wide)}>{t('session.new')}</span>}
|
||||
</span></span>
|
||||
{wide && newShortcut !== undefined && newShortcut.keys.length > 0 && <span className={css.newSessionShortcut} aria-hidden="true">
|
||||
<ShortcutKeys keys={newShortcut.keys} />
|
||||
</span>}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{panels.length > 0 && (
|
||||
<nav className={css.panelList} aria-label={t('panels.label')}>
|
||||
{panels.filter(({ id }) => id === FIXED_PANEL_ID || id === activePanelId || !unpinned.includes(id)).map(({ id, label }) => (
|
||||
{panels.filter(({ id }) => id === FIXED_PANEL_ID || !unpinned.includes(id)).map(({ id, label }) => (
|
||||
<PanelRow
|
||||
key={id}
|
||||
id={id}
|
||||
@@ -367,7 +373,7 @@ export function SidebarRoot({
|
||||
wide={wide}
|
||||
label={t('panels.more')}
|
||||
entries={panels.filter(({ id }) => id !== FIXED_PANEL_ID)
|
||||
.map(({ id, label }) => ({ id, label, pinned: !unpinned.includes(id) }))}
|
||||
.map(({ id, label }) => ({ id, label, pinned: !unpinned.includes(id), active: id === activePanelId }))}
|
||||
onOpen={(id) => {
|
||||
const entry = officialEntry(id)
|
||||
if (entry !== undefined) trackEntryClick({ menu_name: entry, position: 'sidebar_more' })
|
||||
|
||||
@@ -95,6 +95,8 @@ export interface SidebarPanelActionEntry {
|
||||
readonly label: string
|
||||
/** False while the launcher is unpinned from the sidebar. */
|
||||
readonly pinned: boolean
|
||||
/** Whether this panel is selected, independently of its pin state. */
|
||||
readonly active?: boolean
|
||||
}
|
||||
|
||||
/** Owner share of the panel list's More menu rows. */
|
||||
@@ -169,7 +171,7 @@ export type SidebarRootInjected = {
|
||||
hooks: {
|
||||
panels: ObservableSnapshot<readonly SidebarPanelMetadata[]>
|
||||
shortcuts: ObservableSnapshot<readonly ShortcutCatalogEntry[]>
|
||||
/** Ids of unpinned panel launchers, persisted across reloads. */
|
||||
/** Ids of unpinned panel launchers held in memory until plugin reload. */
|
||||
entryPins: ObservableSnapshot<readonly string[]>
|
||||
/** Whether `sidebar.panellist.actions` has registrations. */
|
||||
entryActions: ObservableSnapshot<boolean>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/** Persisted pin choices for the global panel launchers. */
|
||||
/** In-memory pin choices for the global panel launchers. */
|
||||
import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
|
||||
|
||||
/** Panel whose launcher always shows and never enters the customization list. */
|
||||
export const FIXED_PANEL_ID = 'plugins'
|
||||
|
||||
/** Root-level key shared with the slot layer's future per-list preferences. */
|
||||
const STORAGE_KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
|
||||
const NO_PANELS: readonly string[] = []
|
||||
|
||||
/** Unpinned panel ids and their only mutation. */
|
||||
@@ -13,30 +11,23 @@ export interface PanelPins {
|
||||
/** Unpinned panel ids; ids of panels that are not registered stay listed. */
|
||||
readonly unpinned: ObservableSnapshot<readonly string[]>
|
||||
/**
|
||||
* Show or hide one panel launcher; the fixed panel is ignored.
|
||||
* Pin or unpin one panel launcher; the fixed panel is ignored.
|
||||
* @param id - Panel id.
|
||||
* @param pinned - Whether the launcher shows in the sidebar.
|
||||
* @param pinned - Whether the launcher is manually pinned.
|
||||
*/
|
||||
readonly setPinned: (id: string, pinned: boolean) => void
|
||||
}
|
||||
|
||||
/** Stored content is unchecked; anything but a string array leaves every panel pinned. */
|
||||
function unpinnedPanels(value: unknown): readonly string[] {
|
||||
if (typeof value !== 'object' || value === null || !('unpinned' in value)) return NO_PANELS
|
||||
const { unpinned } = value
|
||||
return Array.isArray(unpinned) && unpinned.every((id): id is string => typeof id === 'string') ? unpinned : NO_PANELS
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the persisted panel pin list; the snapshot keeps its reference until the list changes.
|
||||
* @returns Validated unpinned panel ids and the pin mutation.
|
||||
* Create the in-memory panel pin list; the snapshot keeps its reference until the list changes.
|
||||
* @returns Unpinned panel ids and the pin mutation.
|
||||
*/
|
||||
export function createPanelPins(): PanelPins {
|
||||
const store = createSnapshotStore<unknown>({ unpinned: NO_PANELS }, { persist: { name: STORAGE_KEY } })
|
||||
const store = createSnapshotStore<{ readonly unpinned: readonly string[] }>({ unpinned: NO_PANELS })
|
||||
return {
|
||||
unpinned: { getSnapshot: () => unpinnedPanels(store.getSnapshot()), subscribe: listener => store.subscribe(listener) },
|
||||
unpinned: { getSnapshot: () => store.getSnapshot().unpinned, subscribe: listener => store.subscribe(listener) },
|
||||
setPinned: (id, pinned) => {
|
||||
const current = unpinnedPanels(store.getSnapshot())
|
||||
const current = store.getSnapshot().unpinned
|
||||
if (id === FIXED_PANEL_ID || pinned !== current.includes(id)) return
|
||||
store.set({ unpinned: pinned ? current.filter(entry => entry !== id) : [...current, id] })
|
||||
},
|
||||
|
||||
@@ -125,13 +125,23 @@ describe('ui-sidebar apply', () => {
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply })
|
||||
await fiber.await()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(0)
|
||||
const disposeRoot = b.slots.register({
|
||||
expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
|
||||
const declareSidebar = () => b.slots.register({
|
||||
name: 'root',
|
||||
children: { sidebar: { kind: 'single', scope: 'root' } },
|
||||
}, SidebarFrame)
|
||||
const disposeRoot = declareSidebar()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' })
|
||||
disposeRoot()
|
||||
expect(b.slots.entries('sidebar')).toEqual([])
|
||||
expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
|
||||
const disposeReplacement = declareSidebar()
|
||||
expect(b.slots.entries('sidebar')).toHaveLength(1)
|
||||
expect(b.slots.spec('sidebar.panellist')).toEqual({ kind: 'list', scope: 'root' })
|
||||
await fiber.dispose()
|
||||
expect(b.slots.spec('sidebar.panellist')).toBeUndefined()
|
||||
disposeReplacement()
|
||||
})
|
||||
|
||||
it('projects plugin-owned panel entries and removes them with their owner', async () => {
|
||||
|
||||
@@ -91,7 +91,8 @@ function ProbeActions({ entries, onOpen, onPinnedChange, onDismiss }: PropsRunti
|
||||
<>
|
||||
{entries.map(entry => (
|
||||
<span key={entry.id}>
|
||||
<button type="button" role="menuitem" onClick={() => { onOpen(entry.id) }}>Open {entry.label}</button>
|
||||
<button type="button" role="menuitem" aria-current={entry.active ? 'page' : undefined}
|
||||
onClick={() => { onOpen(entry.id) }}>Open {entry.label}</button>
|
||||
<button type="button" role="menuitem" onClick={() => { onPinnedChange(entry.id, !entry.pinned) }}>
|
||||
{entry.pinned ? 'Unpin' : 'Pin'} {entry.label}
|
||||
</button>
|
||||
@@ -323,21 +324,28 @@ describe('sidebar global panels', () => {
|
||||
expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
|
||||
act(() => { locale.setLocale('zh') })
|
||||
expect(view.queryByRole('navigation')).toBeNull()
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
const restored = await view.findByRole('button', { name: '甲面板' })
|
||||
expect(restored.getAttribute('aria-current')).toBe('page')
|
||||
const icon = within(restored).getByTestId(`${ALPHA}-icon`)
|
||||
expect(icon.getAttribute('data-active')).toBe('true')
|
||||
expect(icon.querySelector('svg')?.getAttribute('width')).toBe('16')
|
||||
expect(runtime.slots.entries('sidebar.panellist').map(entry => entry.options.id)).toEqual([BETA, ALPHA])
|
||||
})
|
||||
})
|
||||
|
||||
describe('sidebar launcher pins', () => {
|
||||
const PINS_KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
|
||||
|
||||
it('hides stored unpinned panels, keeps Plugins, and keeps the list while panels are registered', async () => {
|
||||
it('ignores stored unpinned panels and shows all registered launchers', async () => {
|
||||
localStorage.setItem(PINS_KEY, JSON.stringify({ unpinned: [ALPHA, BETA, PLUGINS, 'removed-plugin'] }))
|
||||
const { runtime, locale, view } = await bench()
|
||||
await mountPanels(runtime, locale)
|
||||
const navigation = await view.findByRole('navigation', { name: 'Global panels' })
|
||||
expect(within(navigation).queryAllByRole('button')).toEqual([])
|
||||
expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Beta panel', 'Alpha panel'])
|
||||
await mountPanel(runtime, { id: PLUGINS, heading: 'Plugins content', label: 'Plugins', order: 0 })
|
||||
await waitFor(() => {
|
||||
expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Plugins'])
|
||||
expect(within(navigation).getAllByRole('button').map(row => row.textContent)).toEqual(['Plugins', 'Beta panel', 'Alpha panel'])
|
||||
})
|
||||
expect(runtime.slots.entries('main').map(entry => entry.options.key)).toEqual(['conversation', ALPHA, BETA, PLUGINS])
|
||||
})
|
||||
@@ -377,16 +385,25 @@ describe('sidebar launcher pins', () => {
|
||||
.toEqual(['Open Beta panel', 'Unpin Beta panel', 'Open Alpha panel', 'Unpin Alpha panel', 'Dismiss'])
|
||||
|
||||
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Alpha panel' }))
|
||||
expect(JSON.parse(localStorage.getItem(PINS_KEY) ?? 'null')).toEqual({ unpinned: [ALPHA] })
|
||||
expect(localStorage.getItem(PINS_KEY)).toBeNull()
|
||||
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
|
||||
expect(menu.getByRole('menuitem', { name: 'Pin Alpha panel' })).toBeTruthy()
|
||||
fireEvent.click(menu.getByRole('menuitem', { name: 'Open Alpha panel' }))
|
||||
expect(layout.selectPanel).toHaveBeenLastCalledWith(ALPHA)
|
||||
expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
|
||||
// The open panel keeps its unpinned row as the current page until another panel is selected.
|
||||
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
|
||||
fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Alpha panel' }))
|
||||
const alphaRow = await within(navigation).findByRole('button', { name: 'Alpha panel' })
|
||||
expect(alphaRow.getAttribute('aria-current')).toBe('page')
|
||||
fireEvent.click(menu.getByRole('menuitem', { name: 'Unpin Alpha panel' }))
|
||||
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
|
||||
expect(view.getByRole('heading', { name: 'Alpha content' })).toBeTruthy()
|
||||
expect(menu.getByRole('menuitem', { name: 'Open Alpha panel' }).getAttribute('aria-current')).toBe('page')
|
||||
act(() => { layout.selectPanel(BETA) })
|
||||
await waitFor(() => {
|
||||
expect(menu.getByRole('menuitem', { name: 'Open Alpha panel' }).hasAttribute('aria-current')).toBe(false)
|
||||
expect(menu.getByRole('menuitem', { name: 'Open Beta panel' }).getAttribute('aria-current')).toBe('page')
|
||||
})
|
||||
await waitFor(() => { expect(within(navigation).queryByRole('button', { name: 'Alpha panel' })).toBeNull() })
|
||||
|
||||
fireEvent.click(menu.getByRole('menuitem', { name: 'Pin Alpha panel' }))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Sidebar launcher pins persist per panel id and ignore the fixed Plugins panel. */
|
||||
/** Sidebar launcher pins stay in memory per panel id and ignore the fixed Plugins panel. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createPanelPins } from '../src/client/panel-pins.ts'
|
||||
|
||||
@@ -7,7 +7,7 @@ const KEY = 'dsh.slot.manual-order.sidebar.panellist.v1'
|
||||
afterEach(() => { localStorage.clear(); vi.restoreAllMocks() })
|
||||
|
||||
describe('sidebar panel pins', () => {
|
||||
it('persists unpinned panel ids and restores them in a new instance', () => {
|
||||
it('keeps unpinned panel ids in memory without restoring them in a new instance', () => {
|
||||
const pins = createPanelPins()
|
||||
const listener = vi.fn()
|
||||
pins.unpinned.subscribe(listener)
|
||||
@@ -17,12 +17,12 @@ describe('sidebar panel pins', () => {
|
||||
pins.setPinned('notes', false)
|
||||
pins.setPinned('tasks', false)
|
||||
expect(pins.unpinned.getSnapshot()).toEqual(['notes', 'tasks'])
|
||||
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['notes', 'tasks'] })
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
expect(listener).toHaveBeenCalledTimes(2)
|
||||
expect(createPanelPins().unpinned.getSnapshot()).toEqual(['notes', 'tasks'])
|
||||
expect(createPanelPins().unpinned.getSnapshot()).toEqual([])
|
||||
pins.setPinned('notes', true)
|
||||
expect(pins.unpinned.getSnapshot()).toEqual(['tasks'])
|
||||
expect(JSON.parse(localStorage.getItem(KEY) ?? 'null')).toEqual({ unpinned: ['tasks'] })
|
||||
expect(localStorage.getItem(KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores Plugins and repeated choices without republishing', () => {
|
||||
@@ -39,20 +39,14 @@ describe('sidebar panel pins', () => {
|
||||
})
|
||||
|
||||
it('keeps ids of panels that are not registered', () => {
|
||||
localStorage.setItem(KEY, JSON.stringify({ unpinned: ['removed-plugin'] }))
|
||||
const pins = createPanelPins()
|
||||
pins.setPinned('removed-plugin', false)
|
||||
pins.setPinned('notes', false)
|
||||
expect(pins.unpinned.getSnapshot()).toEqual(['removed-plugin', 'notes'])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-array list', { unpinned: 'notes' }],
|
||||
['non-string ids', { unpinned: ['notes', 1] }],
|
||||
['a missing list', { order: ['notes'] }],
|
||||
['null', null],
|
||||
['a string', 'notes'],
|
||||
])('reads %s as no unpinned panels 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: ['notes'] }))
|
||||
const pins = createPanelPins()
|
||||
const snapshot = pins.unpinned.getSnapshot()
|
||||
expect(snapshot).toEqual([])
|
||||
|
||||
@@ -157,6 +157,37 @@ describe('SidebarRoot shell', () => {
|
||||
expect(b.trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'sidebar' })
|
||||
})
|
||||
|
||||
it('offers no New Session control while the access snapshot excludes session/create', () => {
|
||||
const toggleSidebar = vi.fn()
|
||||
const startSession = vi.fn()
|
||||
const useRemoteAccess = vi.fn((action: string) => action !== 'session/create')
|
||||
render(<SidebarRoot
|
||||
collapsed={false} width={300}
|
||||
useSessions={neverHook} useSessionStatus={useSessionStatus} useSessionRetainInfo={neverHook}
|
||||
usePanelInfo={usePanelInfo} selectPanel={() => {}} usePanels={selector => selector([])} useEntryPins={selector => selector([])}
|
||||
useEntryActions={selector => selector(false)} setPinned={vi.fn()}
|
||||
useShortcuts={selector => selector([])}
|
||||
useResource={useResource} useWorkspaces={neverHook}
|
||||
{...allowAllAccess}
|
||||
useRemoteAccess={useRemoteAccess}
|
||||
startSession={startSession} toggleSidebar={toggleSidebar} trackEntryClick={vi.fn()} t={t}
|
||||
renderSlot={((key: string) => {
|
||||
if (key === 'sidebar.brand.mark') return <span data-testid="brand-mark">M</span>
|
||||
if (key === 'sidebar.brand.name') return <span data-testid="brand-name">Brand</span>
|
||||
return null
|
||||
}) as SidebarRootComponentProps['renderSlot']}
|
||||
/>)
|
||||
|
||||
// Neither the brand row nor the dedicated button starts a Session; the brand still renders.
|
||||
expect(useRemoteAccess).toHaveBeenCalledWith('session/create')
|
||||
expect(screen.queryByRole('button', { name: 'New session' })).toBeNull()
|
||||
expect(screen.getByTestId('brand-mark')).toBeTruthy()
|
||||
expect(screen.getByTestId('brand-name')).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
|
||||
expect(toggleSidebar).toHaveBeenCalledOnce()
|
||||
expect(startSession).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders generic brand fallbacks when no package fills the slots', () => {
|
||||
vi.stubEnv('DSH_CLIENT_COMMIT_HASH', '0123456')
|
||||
vi.stubEnv('DSH_CLIENT_GIT_DIRTY', 'true')
|
||||
@@ -359,8 +390,8 @@ it('wires the shell.leading controls to the shared sidebar actions', () => {
|
||||
const toggleSidebar = vi.fn()
|
||||
const startSession = vi.fn()
|
||||
const trackEntryClick = vi.fn<HeaderLeadingControlsProps['trackEntryClick']>()
|
||||
// This occupant only consumes its actions and locale, not Session hooks.
|
||||
const props = { toggleSidebar, startSession, trackEntryClick, t, useShortcuts: select => select([{ id: 'sidebar.left.toggle' as ShortcutCommandId, label: 'Toggle sidebar', aliases: [], binding: null, modified: false, conflicts: [], issue: null, keys: ['⌘', 'B'], aria: 'Meta+B' }]) } as HeaderLeadingControlsProps
|
||||
// This occupant only consumes its actions, the global access Hook, and locale, not Session hooks.
|
||||
const props = { toggleSidebar, startSession, trackEntryClick, t, useRemoteAccess: allowAllAccess.useRemoteAccess, useShortcuts: select => select([{ id: 'sidebar.left.toggle' as ShortcutCommandId, label: 'Toggle sidebar', aliases: [], binding: null, modified: false, conflicts: [], issue: null, keys: ['⌘', 'B'], aria: 'Meta+B' }]) } as HeaderLeadingControlsProps
|
||||
render(<HeaderLeadingControls {...props} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: en['toggle.open'] }))
|
||||
expect(toggleSidebar).toHaveBeenCalledOnce()
|
||||
@@ -370,6 +401,12 @@ it('wires the shell.leading controls to the shared sidebar actions', () => {
|
||||
// The window-chrome New Session control reports a titlebar click before starting the Session.
|
||||
expect(trackEntryClick).toHaveBeenCalledExactlyOnceWith({ menu_name: 'new_session', position: 'titlebar' })
|
||||
expect(trackEntryClick.mock.invocationCallOrder[0]).toBeLessThan(startSession.mock.invocationCallOrder[0]!)
|
||||
cleanup()
|
||||
|
||||
// A page whose access snapshot excludes starting a Session keeps only the sidebar toggle.
|
||||
render(<HeaderLeadingControls {...props} useRemoteAccess={action => action !== 'session/create'} />)
|
||||
expect(screen.getByRole('button', { name: en['toggle.open'] })).toBeTruthy()
|
||||
expect(screen.queryByRole('button', { name: en['session.new.label'] })).toBeNull()
|
||||
})
|
||||
|
||||
describe('Windows caption New Session analytics', () => {
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 9fb88260380bf093
|
||||
zh: 3bace2b96ecd4a82
|
||||
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
|
||||
en: ac15c05cb045f748
|
||||
zh: 6330172c7231b2b8
|
||||
en: 2168316f604893a5
|
||||
zh: fe11347009339a9a
|
||||
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
|
||||
en: 96a37e74555a36b7
|
||||
zh: d03941523509a5cf
|
||||
|
||||
@@ -73,7 +73,9 @@ Grouped and flat Session rows show a clock mark when the Session has active sche
|
||||
|
||||
-----
|
||||
|
||||
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; New Session opens the same blank as Workspace reopening, so it returns to that blank's unsent draft. `forkSession(id)` creates the child without navigating or superseding a pending navigation. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
|
||||
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; New Session opens the same blank as Workspace reopening, so it returns to that blank's unsent draft. `forkSession(id, onCreated?, options?)` creates the child without navigating or superseding a pending navigation; `allowMigration` carries the caller's migration permission. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation.
|
||||
|
||||
Session and Workspace rows derive their affordances from the browser root's access Hooks: the global `useRemoteAccess('session/create')` gates every New Session entry, `useWorkspaceAccess` gates a Workspace's create, rename, delete, and reorder verbs, and `useSessionAccess` gates a Session row's rename, fork, drag, pin, archive, and unarchive verbs plus the search result's restore control. A refused verb is not rendered, while every Host request remains authoritative. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
|
||||
|
||||
Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent, except that restoring a saved blank first reloads that Session's projections, because listed values are cached hints that can lag its log after an unclean Host exit.
|
||||
|
||||
|
||||
@@ -73,7 +73,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
|
||||
|
||||
-----
|
||||
|
||||
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;新会话与重新打开工作区打开同一个空白会话,因此会回到该空白会话中未发送的草稿。`forkSession(id)` 创建子会话,不导航,也不替代尚未完成的导航。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
|
||||
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;新会话与重新打开工作区打开同一个空白会话,因此会回到该空白会话中未发送的草稿。`forkSession(id, onCreated?, options?)` 创建子会话,不导航,也不替代尚未完成的导航;`allowMigration` 携带调用方的迁移许可。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。
|
||||
|
||||
Session 行与 Workspace 行的可用操作来自浏览器根的访问 Hook:全局 `useRemoteAccess('session/create')` 门控所有新建会话入口,`useWorkspaceAccess` 门控 Workspace 的新建、重命名、删除与排序动作,`useSessionAccess` 门控 Session 行的重命名、分叉、拖拽、置顶、归档与取消归档动作,以及搜索结果的恢复控件。被拒绝的动作不渲染,但每个发往 Host 的请求仍以其授权为准。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
|
||||
|
||||
导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影;例外是恢复已保存的空白会话时先重新加载该 Session 的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于其日志。
|
||||
|
||||
|
||||
@@ -71,9 +71,10 @@ export interface UiWorkspace {
|
||||
options?: Pick<Parameters<ISessions['fork']>[0], 'allowMigration'>,
|
||||
): Promise<SessionId>
|
||||
/**
|
||||
* Resolve the Session for a new conversation in a Workspace: navigation names the first
|
||||
* reusable blank member, skipping blanks that list a preset other than a requested one, and
|
||||
* the Host reuses it under the requested preset or its default, or creates another Session.
|
||||
* Resolve the Session for a new conversation in a Workspace: navigation names the first reusable
|
||||
* blank member — listed in the Workspace, not archived, and listing no preset or the requested one —
|
||||
* and the Host reuses it under the requested preset or its default, creating another Session when it
|
||||
* reuses none. Concurrent calls for the same Workspace and preset share one attempt.
|
||||
* @param workspaceId - target Workspace.
|
||||
* @param options - choices for the created or reused Session.
|
||||
* @returns a Session already addressable through the Session Controller.
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import type { ShortcutCatalogEntry } from '@deepseek-ai/dsh-client-shortcuts/client'
|
||||
import type { StateDotState } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { MenuOpenState, WorkspaceBrowserProps } from '../contract/slots.ts'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../tree.ts'
|
||||
import css from './Rows.module.css'
|
||||
@@ -203,21 +204,8 @@ function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' |
|
||||
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
|
||||
}
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: folder + title;
|
||||
* hover reveals the chevron and create button, and dwelling on a real
|
||||
* Workspace shows its hover card (the ungrouped bucket has none).
|
||||
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
|
||||
* @param props.group - derived group node.
|
||||
* @param props.containsCurrentDescendant - highlight an ancestor even when its subtree is collapsed.
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @param props.drag - optional workspace-row drag wiring.
|
||||
* @param props.home - host account home for POSIX hover-path abbreviation.
|
||||
* @param props.t - the browser root's locale seat.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem({ group, containsCurrentDescendant = false, onToggle, onCreate, actions, drag, home, newShortcut, t }: {
|
||||
/** Owner props of one Workspace header row; the access Hooks are the browser root's own standard seats. */
|
||||
type ProjectRowItemProps = {
|
||||
group: GroupNode
|
||||
newShortcut?: ShortcutCatalogEntry | undefined
|
||||
containsCurrentDescendant?: boolean
|
||||
@@ -230,7 +218,62 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
|
||||
/** Host account home; POSIX home-rooted hover paths display as `~`. */
|
||||
home?: string | undefined
|
||||
t: RowTranslate
|
||||
}) {
|
||||
} & Pick<WorkspaceBrowserProps, 'useRemoteAccess' | 'useWorkspaceAccess'>
|
||||
|
||||
/** Which of one header row's actions the caller's access snapshot allows. */
|
||||
interface ProjectRowAccess {
|
||||
readonly create: boolean
|
||||
readonly rename: boolean
|
||||
readonly delete: boolean
|
||||
readonly reorder: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project (workspace) header row: folder + title;
|
||||
* hover reveals the chevron and create button, and dwelling on a real
|
||||
* Workspace shows its hover card (the ungrouped bucket has none).
|
||||
* `containsCurrent` arrives on the node (derivation fact, no renderer scan).
|
||||
* The create button, each Workspace menu row, and the drag handle are absent
|
||||
* while the access snapshot excludes their endpoint: a real Workspace asks
|
||||
* its own resource, and the ungrouped bucket asks the global resource.
|
||||
* @param props.group - derived group node.
|
||||
* @param props.containsCurrentDescendant - highlight an ancestor even when its subtree is collapsed.
|
||||
* @param props.onToggle - expand/collapse the group.
|
||||
* @param props.onCreate - start a frontend Session inside this Workspace.
|
||||
* @param props.drag - optional workspace-row drag wiring.
|
||||
* @param props.home - host account home for POSIX hover-path abbreviation.
|
||||
* @param props.t - the browser root's locale seat.
|
||||
* @returns the row element.
|
||||
*/
|
||||
export function ProjectRowItem(props: ProjectRowItemProps) {
|
||||
const workspaceId = props.group.workspaceId
|
||||
return workspaceId === undefined
|
||||
? <UngroupedProjectRow {...props} />
|
||||
: <WorkspaceProjectRow {...props} workspaceId={workspaceId} />
|
||||
}
|
||||
|
||||
/** The ungrouped bucket's header: no Workspace resource, so starting a Session there asks the global resource. */
|
||||
function UngroupedProjectRow(props: ProjectRowItemProps) {
|
||||
const create = props.useRemoteAccess('session/create')
|
||||
return <ProjectRow {...props} access={{ create, rename: false, delete: false, reorder: false }} />
|
||||
}
|
||||
|
||||
/** A real Workspace's header: every action asks that Workspace's resource. */
|
||||
function WorkspaceProjectRow({ workspaceId, ...props }: ProjectRowItemProps & { workspaceId: WorkspaceId }) {
|
||||
const { useWorkspaceAccess } = props
|
||||
const access: ProjectRowAccess = {
|
||||
create: useWorkspaceAccess(workspaceId, 'session/create'),
|
||||
rename: useWorkspaceAccess(workspaceId, 'workspace/rename'),
|
||||
delete: useWorkspaceAccess(workspaceId, 'workspace/delete'),
|
||||
reorder: useWorkspaceAccess(workspaceId, 'workspace/insertBefore'),
|
||||
}
|
||||
return <ProjectRow {...props} access={access} />
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
group, containsCurrentDescendant = false, onToggle, onCreate, actions, drag: offeredDrag, home, newShortcut, t, access,
|
||||
}: ProjectRowItemProps & { access: ProjectRowAccess }) {
|
||||
const drag = access.reorder ? offeredDrag : undefined
|
||||
const row = group
|
||||
// The ungrouped bucket has no workspace title: its label is dictionary copy.
|
||||
const label = row.workspaceId === undefined ? t('group.ungrouped') : row.label
|
||||
@@ -238,8 +281,8 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const dragPreview = useRowDragPreview(css.dragPreview as string)
|
||||
const workspaceMenuItems = [
|
||||
{ id: 'rename', label: t('rename'), icon: <IconEditOutlineRegular /> },
|
||||
{ id: 'delete', label: t('delete.workspace'), icon: <IconTrashOutlineRegular />, danger: true },
|
||||
...access.rename ? [{ id: 'rename', label: t('rename'), icon: <IconEditOutlineRegular /> }] : [],
|
||||
...access.delete ? [{ id: 'delete', label: t('delete.workspace'), icon: <IconTrashOutlineRegular />, danger: true }] : [],
|
||||
]
|
||||
const ownRow = (
|
||||
<div
|
||||
@@ -269,7 +312,7 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
|
||||
<span className={css.title}>{label}</span>
|
||||
</span>
|
||||
<span className={css.rowActions}>
|
||||
{actions !== undefined && (
|
||||
{actions !== undefined && workspaceMenuItems.length > 0 && (
|
||||
<Menu
|
||||
open={menuOpen}
|
||||
onClose={() => { setMenuOpen(false) }}
|
||||
@@ -297,17 +340,19 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Tooltip label={t('actions.newSession')} shortcutKeys={newShortcut?.keys} side="bottom" align="end" delayMs={500} portal>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-keyshortcuts={newShortcut?.aria}
|
||||
aria-label={t('actions.newSession.aria', { name: label })}
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconNewChatOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
{access.create && (
|
||||
<Tooltip label={t('actions.newSession')} shortcutKeys={newShortcut?.keys} side="bottom" align="end" delayMs={500} portal>
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
aria-keyshortcuts={newShortcut?.aria}
|
||||
aria-label={t('actions.newSession.aria', { name: label })}
|
||||
onClick={(e) => { e.stopPropagation(); onCreate() }}
|
||||
>
|
||||
<IconNewChatOutlineRegular />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
@@ -472,17 +517,22 @@ function SessionHoverContent({ node, cwd, now, renderSlot, t }: {
|
||||
* @param props.currentId - selected session id.
|
||||
* @param props.onOpen - open the selected session.
|
||||
* @param props.onUnarchive - unarchive an archived result row.
|
||||
* @param props.useSessionAccess - the browser root's Session access Hook; the restore control
|
||||
* appears only while the snapshot allows `workspace/unarchiveSession` for that Session.
|
||||
* @param props.t - Workspace-browser translation seat.
|
||||
* @returns the result row.
|
||||
*/
|
||||
export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }: {
|
||||
export function SearchResultItem({
|
||||
result, currentId, onOpen, onUnarchive, useSessionAccess, t,
|
||||
}: {
|
||||
result: SearchResultNode
|
||||
currentId: string | undefined
|
||||
onOpen: (id: SearchResultNode['id']) => void
|
||||
onUnarchive: (id: SearchResultNode['id']) => void
|
||||
t: RowTranslate
|
||||
}) {
|
||||
} & Pick<WorkspaceBrowserProps, 'useSessionAccess'>) {
|
||||
const selected = result.id === currentId
|
||||
const canUnarchive = useSessionAccess(result.id, 'workspace/unarchiveSession')
|
||||
const statuses = sessionStatuses(result, t)
|
||||
const primaryStatus = statuses[0]
|
||||
return (
|
||||
@@ -503,7 +553,7 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
|
||||
)}
|
||||
</span>
|
||||
<span className={css.searchResultTitle}>{result.title || t('session.untitled')}</span>
|
||||
{result.archived && (
|
||||
{result.archived && canUnarchive && (
|
||||
<span className={css.rowActions}>
|
||||
<Tooltip label={t('actions.unarchive')} side="bottom" align="end" delayMs={500} portal>
|
||||
<button
|
||||
@@ -547,13 +597,16 @@ export function SearchResultItem({ result, currentId, onOpen, onUnarchive, t }:
|
||||
* (`sidebar.workspaces.session.menu.item` / `sidebar.workspaces.session.row.action`),
|
||||
* its leading and trailing decorations, and its hover-card section.
|
||||
* @param props.onReveal - scroll this row into view after search navigation, then acknowledge it.
|
||||
* @param props.drag - optional row-drag target wiring; blank rows cannot start a drag.
|
||||
* @param props.drag - optional row-drag target wiring; blank rows, and rows whose access excludes
|
||||
* `workspace/insertSessionBefore`, cannot start a drag.
|
||||
* @param props.home - host account home for POSIX hover-path abbreviation.
|
||||
* @param props.useSessionAccess - the browser root's Session access Hook; the title double-click
|
||||
* renames only while it allows `session/rename`.
|
||||
* @param props.t - the browser root's locale seat.
|
||||
* @returns the session row.
|
||||
*/
|
||||
export function SessionNodeItem({
|
||||
node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, t,
|
||||
node, currentId, now, onOpen, onRenameRequest, renderSlot, onReveal, drag, home, useSessionAccess, t,
|
||||
}: {
|
||||
node: SessionNode
|
||||
currentId: string | undefined
|
||||
@@ -568,7 +621,7 @@ export function SessionNodeItem({
|
||||
/** Host account home; POSIX home-rooted hover paths display as `~`. */
|
||||
home?: string | undefined
|
||||
t: RowTranslate
|
||||
} & PropsRenderSlots<
|
||||
} & Pick<WorkspaceBrowserProps, 'useSessionAccess'> & PropsRenderSlots<
|
||||
| 'sidebar.workspaces.session.menu.item'
|
||||
| 'sidebar.workspaces.session.row.action'
|
||||
| 'sidebar.session.row.trailing'
|
||||
@@ -584,7 +637,11 @@ export function SessionNodeItem({
|
||||
// Archived rows hold their in-place grayed slot, so manual reorder cannot
|
||||
// move them. Pinned rows drag within the pinned block: the browser gates
|
||||
// their drop targets to fellow pinned rows.
|
||||
const draggable = drag !== undefined && !row.blank && !row.archived
|
||||
// Renaming from the title and starting a drag ask the row's own access snapshot; a row that
|
||||
// cannot start a drag still accepts drops from rows that can.
|
||||
const renamable = useSessionAccess(node.id, 'session/rename')
|
||||
const reorderable = useSessionAccess(node.id, 'workspace/insertSessionBefore')
|
||||
const draggable = drag !== undefined && reorderable && !row.blank && !row.archived
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
const dragPreview = useRowDragPreview(css.dragPreview as string)
|
||||
// The menu's open state, bound into the row entries' `useMenuOpenState` hook.
|
||||
@@ -670,7 +727,7 @@ export function SessionNodeItem({
|
||||
// One shared function and options: repeated presses add one listener.
|
||||
window.addEventListener('blur', disarmRename, { once: true })
|
||||
}}
|
||||
onDoubleClick={row.blank
|
||||
onDoubleClick={row.blank || !renamable
|
||||
? undefined
|
||||
: (e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -253,6 +253,7 @@ type SessionTreeProps = Pick<
|
||||
WorkspaceBrowserProps,
|
||||
'useSessionStatus' | 'startSession' | 'open'
|
||||
| 'insertWorkspaceBefore' | 't' | 'usePanelInfo'
|
||||
| 'useRemoteAccess' | 'useSessionAccess' | 'useWorkspaceAccess'
|
||||
> & PropsRenderSlots<
|
||||
| 'sidebar.workspaces.session.menu.item'
|
||||
| 'sidebar.workspaces.session.row.action'
|
||||
@@ -317,7 +318,7 @@ function EmptySessions({ rowState, onLeaveArchivedOnly, t }: Pick<SessionTreePro
|
||||
function SessionTree({
|
||||
list, useSessionStatus, startSession, open, workspaces, ungroupedSessionIds,
|
||||
rowState, onLeaveArchivedOnly,
|
||||
workspaceReady, animationResetKey, usePanelInfo,
|
||||
workspaceReady, animationResetKey, usePanelInfo, useRemoteAccess, useSessionAccess, useWorkspaceAccess,
|
||||
onRenameRequest, onDeleteRequest, onSessionRenameRequest,
|
||||
renderSlot,
|
||||
insertWorkspaceBefore,
|
||||
@@ -557,6 +558,8 @@ function SessionTree({
|
||||
<ProjectRowItem
|
||||
newShortcut={shortcuts.find(row => row.id === 'session.new')}
|
||||
group={group}
|
||||
useRemoteAccess={useRemoteAccess}
|
||||
useWorkspaceAccess={useWorkspaceAccess}
|
||||
containsCurrentDescendant={currentAncestors.has(group.key)}
|
||||
home={home}
|
||||
t={t}
|
||||
@@ -634,6 +637,7 @@ function SessionTree({
|
||||
: undefined}
|
||||
drag={dragProps}
|
||||
home={home}
|
||||
useSessionAccess={useSessionAccess}
|
||||
t={t}
|
||||
/>
|
||||
)
|
||||
@@ -689,12 +693,13 @@ function SessionTree({
|
||||
|
||||
/** The flat "In one list" body: every session is one draggable top-level row. */
|
||||
function FlatList({
|
||||
list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, open, onSessionRenameRequest,
|
||||
list, sessionIds, rowState, onLeaveArchivedOnly, useSessionStatus, useSessionAccess, open, onSessionRenameRequest,
|
||||
usePanelInfo, setSessionOrder, workspaceReady, animationResetKey,
|
||||
revealSessionId, onSessionRevealed, renderSlot, home, t,
|
||||
}: Pick<
|
||||
SessionTreeProps,
|
||||
| 'useSessionStatus'
|
||||
| 'useSessionAccess'
|
||||
| 'open'
|
||||
| 'onSessionRenameRequest'
|
||||
| 'renderSlot'
|
||||
@@ -761,6 +766,7 @@ function FlatList({
|
||||
onReveal={node.id === revealSessionId
|
||||
? () => { onSessionRevealed(node.id) }
|
||||
: undefined}
|
||||
useSessionAccess={useSessionAccess}
|
||||
drag={{
|
||||
start: () => {
|
||||
dropCommitted.current = false
|
||||
@@ -813,8 +819,9 @@ function SearchResults({
|
||||
remote,
|
||||
resultLimit,
|
||||
usePanelInfo,
|
||||
useSessionAccess,
|
||||
t,
|
||||
}: Pick<WorkspaceBrowserProps, 'useSessions' | 'useSessionStatus' | 'open' | 't' | 'usePanelInfo'> & {
|
||||
}: Pick<WorkspaceBrowserProps, 'useSessions' | 'useSessionStatus' | 'open' | 't' | 'usePanelInfo' | 'useSessionAccess'> & {
|
||||
workspaces: readonly WorkspaceView[]
|
||||
archivedSessionIds: readonly SessionNode['id'][]
|
||||
/** Search matches follow the archived filter selected for the list. */
|
||||
@@ -860,6 +867,7 @@ function SearchResults({
|
||||
currentId={currentId}
|
||||
onOpen={open}
|
||||
onUnarchive={onUnarchive}
|
||||
useSessionAccess={useSessionAccess}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
@@ -904,6 +912,9 @@ export function WorkspaceBrowser({
|
||||
expandSidebar,
|
||||
useSessions,
|
||||
useSessionStatus,
|
||||
useRemoteAccess,
|
||||
useSessionAccess,
|
||||
useWorkspaceAccess,
|
||||
useWorkspaces,
|
||||
useStore,
|
||||
actions,
|
||||
@@ -1409,6 +1420,7 @@ export function WorkspaceBrowser({
|
||||
? (
|
||||
<SearchResults
|
||||
usePanelInfo={usePanelInfo}
|
||||
useSessionAccess={useSessionAccess}
|
||||
useSessions={useSessions}
|
||||
useSessionStatus={useSessionStatus}
|
||||
open={openSearchResult}
|
||||
@@ -1425,6 +1437,7 @@ export function WorkspaceBrowser({
|
||||
: groupBy === 'flat'
|
||||
? (
|
||||
<FlatList
|
||||
useSessionAccess={useSessionAccess}
|
||||
usePanelInfo={usePanelInfo}
|
||||
list={list}
|
||||
sessionIds={orderedFlatSessionIds}
|
||||
@@ -1445,6 +1458,9 @@ export function WorkspaceBrowser({
|
||||
)
|
||||
: (
|
||||
<SessionTree
|
||||
useRemoteAccess={useRemoteAccess}
|
||||
useSessionAccess={useSessionAccess}
|
||||
useWorkspaceAccess={useWorkspaceAccess}
|
||||
usePanelInfo={usePanelInfo}
|
||||
list={list}
|
||||
shortcuts={shortcuts}
|
||||
|
||||
@@ -24,16 +24,19 @@ import css from '../rows/Rows.module.css'
|
||||
import browserCss from '../rows/WorkspaceBrowser.module.css'
|
||||
|
||||
/**
|
||||
* Menu row (order 400): archive, or restore an archived row.
|
||||
* @param props - owner share, the archive share, and the menu open state.
|
||||
* @returns the row.
|
||||
* Menu row (order 400): archive, or restore an archived row. Absent while the Session's access
|
||||
* snapshot excludes that toggle.
|
||||
* @param props - owner share, the archive share, the access Hook, and the menu open state.
|
||||
* @returns the row, or nothing when the caller may not toggle this Session's archival.
|
||||
*/
|
||||
export function ArchiveSessionMenuItem({
|
||||
sessionId, useArchived, useMenuOpenState, useShortcuts, archiveSession, unarchiveSession, t,
|
||||
sessionId, useArchived, useMenuOpenState, useShortcuts, useSessionAccess, archiveSession, unarchiveSession, t,
|
||||
}: SessionMenuItemProps<ArchiveSessionInjected>) {
|
||||
const [, setMenuOpen] = useMenuOpenState()
|
||||
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.archive'))
|
||||
const archived = useArchived(set => set.has(sessionId))
|
||||
const allowed = useSessionAccess(sessionId, archived ? 'workspace/unarchiveSession' : 'workspace/archiveSession')
|
||||
if (!allowed) return null
|
||||
return (
|
||||
<MenuItemButton
|
||||
shortcut={archived ? undefined : shortcut}
|
||||
@@ -49,14 +52,17 @@ export function ArchiveSessionMenuItem({
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover button (order 100): archive, or restore an archived row.
|
||||
* @param props - owner share and the archive share.
|
||||
* @returns the button.
|
||||
* Hover button (order 100): archive, or restore an archived row. Absent while the Session's access
|
||||
* snapshot excludes that toggle.
|
||||
* @param props - owner share, the access Hook, and the archive share.
|
||||
* @returns the button, or nothing when the caller may not toggle this Session's archival.
|
||||
*/
|
||||
export function ArchiveSessionRowButton({
|
||||
sessionId, useArchived, archiveSession, unarchiveSession, t,
|
||||
sessionId, useArchived, useSessionAccess, archiveSession, unarchiveSession, t,
|
||||
}: SessionRowActionProps<ArchiveSessionInjected>) {
|
||||
const archived = useArchived(set => set.has(sessionId))
|
||||
const allowed = useSessionAccess(sessionId, archived ? 'workspace/unarchiveSession' : 'workspace/archiveSession')
|
||||
if (!allowed) return null
|
||||
return (
|
||||
<Tooltip label={t(archived ? 'actions.unarchive' : 'actions.archive')} side="bottom" align="end" delayMs={500} portal>
|
||||
<button
|
||||
|
||||
@@ -12,26 +12,29 @@ import {
|
||||
import type { PinSessionInjected, SessionMenuItemProps, SessionRowActionProps } from '../contract/slots.ts'
|
||||
import css from '../rows/Rows.module.css'
|
||||
|
||||
type PinState = Pick<SessionMenuItemProps<PinSessionInjected>, 'sessionId' | 'usePinned' | 'useArchived'>
|
||||
type PinState = Pick<SessionMenuItemProps<PinSessionInjected>, 'sessionId' | 'usePinned' | 'useArchived' | 'useSessionAccess'>
|
||||
|
||||
/** The row's pin and archive membership, one Set lookup each. */
|
||||
function usePinState({ sessionId, usePinned, useArchived }: PinState) {
|
||||
/** The row's pin and archive membership, one Set lookup each, and whether its access allows the toggle. */
|
||||
function usePinState({ sessionId, usePinned, useArchived, useSessionAccess }: PinState) {
|
||||
const pinned = usePinned(set => set.has(sessionId))
|
||||
return {
|
||||
pinned: usePinned(pinned => pinned.has(sessionId)),
|
||||
pinned,
|
||||
archived: useArchived(archived => archived.has(sessionId)),
|
||||
allowed: useSessionAccess(sessionId, pinned ? 'workspace/unpinSession' : 'workspace/pinSession'),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Menu row (order 100): pin or unpin by the row's current state; absent on archived rows.
|
||||
* @param props - owner share, the pin share, and the menu open state.
|
||||
* @returns the row, or null for an archived Session.
|
||||
* Menu row (order 100): pin or unpin by the row's current state; absent on archived rows and while
|
||||
* the Session's access snapshot excludes that toggle.
|
||||
* @param props - owner share, the pin share, the access Hook, and the menu open state.
|
||||
* @returns the row, or null for an archived or refused Session.
|
||||
*/
|
||||
export function PinSessionMenuItem(props: SessionMenuItemProps<PinSessionInjected>) {
|
||||
const { sessionId, useMenuOpenState, pinSession, unpinSession, t } = props
|
||||
const [, setMenuOpen] = useMenuOpenState()
|
||||
const { pinned, archived } = usePinState(props)
|
||||
if (archived) return null
|
||||
const { pinned, archived, allowed } = usePinState(props)
|
||||
if (archived || !allowed) return null
|
||||
return (
|
||||
<MenuItemButton
|
||||
icon={pinned ? <IconPinFillRegular /> : <IconPinOutlineRegular />}
|
||||
@@ -46,14 +49,15 @@ export function PinSessionMenuItem(props: SessionMenuItemProps<PinSessionInjecte
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover button (order 200, rightmost: it lands where the rest-state pin marker sits); absent on archived rows.
|
||||
* @param props - owner share and the pin share.
|
||||
* @returns the button, or null for an archived Session.
|
||||
* Hover button (order 200, rightmost: it lands where the rest-state pin marker sits); absent on
|
||||
* archived rows and while the Session's access snapshot excludes that toggle.
|
||||
* @param props - owner share, the access Hook, and the pin share.
|
||||
* @returns the button, or null for an archived or refused Session.
|
||||
*/
|
||||
export function PinSessionRowButton(props: SessionRowActionProps<PinSessionInjected>) {
|
||||
const { sessionId, pinSession, unpinSession, t } = props
|
||||
const { pinned, archived } = usePinState(props)
|
||||
if (archived) return null
|
||||
const { pinned, archived, allowed } = usePinState(props)
|
||||
if (archived || !allowed) return null
|
||||
return (
|
||||
<Tooltip label={t(pinned ? 'actions.unpin' : 'actions.pin')} side="bottom" align="end" delayMs={500} portal>
|
||||
<button
|
||||
|
||||
@@ -12,15 +12,18 @@ import type {
|
||||
import css from '../rows/WorkspaceBrowser.module.css'
|
||||
|
||||
/**
|
||||
* Menu row (order 200): ask for the rename dialog, seeded with the row's current title.
|
||||
* @param props - owner share, menu open state, and the rename share.
|
||||
* @returns the row.
|
||||
* Menu row (order 200): ask for the rename dialog, seeded with the row's current title. Absent while
|
||||
* the Session's access snapshot excludes `session/rename`.
|
||||
* @param props - owner share, menu open state, access Hook, and the rename share.
|
||||
* @returns the row, or nothing when the caller may not rename this Session.
|
||||
*/
|
||||
export function RenameSessionMenuItem({
|
||||
sessionId, displayTitle, useMenuOpenState, useShortcuts, requestSessionRename, t,
|
||||
sessionId, displayTitle, useMenuOpenState, useShortcuts, useSessionAccess, requestSessionRename, t,
|
||||
}: SessionMenuItemProps<RenameSessionInjected>) {
|
||||
const [, setMenuOpen] = useMenuOpenState()
|
||||
const shortcut = useShortcuts(rows => rows.find(row => row.id === 'session.rename'))
|
||||
const renameAllowed = useSessionAccess(sessionId, 'session/rename')
|
||||
if (!renameAllowed) return null
|
||||
return (
|
||||
<MenuItemButton
|
||||
shortcut={shortcut}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Workspace header actions under a non-owner access snapshot, through the assembled renderer.
|
||||
*
|
||||
* Hand-built row props spread allow-all access Hooks, so they cannot show that the browsing region
|
||||
* reads the Hooks a page actually binds. This spec replaces the runtime's allow-all access row with the
|
||||
* remote-access snapshot owner itself and renders the real slot tree, so the global and Workspace
|
||||
* access Hooks are the renderer's bindings over one non-owner snapshot.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, screen } from '@testing-library/react'
|
||||
import type { TypertResourceKind } from '@deepseek-ai/dsh-api-remote-access/client'
|
||||
import { ClientAccessState } from '@deepseek-ai/dsh-api-remote-access/src/client/state.ts'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { allowAllAccessPlugin, SlotTestRuntime, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
|
||||
usePinnedBrowserLanguages('zh-CN')
|
||||
|
||||
afterEach(cleanup)
|
||||
beforeEach(() => { localStorage.clear() })
|
||||
|
||||
/** Test-owned sidebar shell role: declares and renders the browsing region. */
|
||||
type FrameProps = PropsRenderSlots<'sidebar.workspaces'>
|
||||
function SidebarFrame({ renderSlot }: FrameProps) {
|
||||
return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })}</>
|
||||
}
|
||||
|
||||
const WORKSPACE = 'w1' as WorkspaceId
|
||||
|
||||
/**
|
||||
* The assembled browsing region over one Workspace, answering access from one snapshot owner.
|
||||
* @param access - Snapshot owner the page's access Hooks read.
|
||||
* @returns The runtime.
|
||||
*/
|
||||
async function bench(access: ClientAccessState, options: { ungroupedSession?: SessionId } = {}) {
|
||||
const runtime = await SlotTestRuntime.create()
|
||||
await Promise.all([...runtime.ctx.registry.get(allowAllAccessPlugin)?.fibers ?? []].map(fiber => fiber.dispose()))
|
||||
await runtime.ctx.plugin({
|
||||
name: 'access-assembly:snapshot-access',
|
||||
inject: ['slots'],
|
||||
apply(ctx) {
|
||||
ctx.provide('remoteAccess', {
|
||||
source: (kind: TypertResourceKind, id: string, action: string) => access.resource(kind, id, action),
|
||||
})
|
||||
ctx.slots.provideRoot({ keyedHooks: { remoteAccess: action => access.global(action) } })
|
||||
},
|
||||
}).await()
|
||||
runtime.ctx.provide('shortcuts', { register: () => () => {}, catalog: createSnapshotStore([]) })
|
||||
runtime.ctx.provide('uiConversation', {})
|
||||
runtime.ctx.provide('layout', { selectPanel: vi.fn(), beginNavigation: () => new AbortController().signal })
|
||||
runtime.releaseWorkspaceSource()
|
||||
runtime.remote.provideNamespaces({ directoryPicker: {} })
|
||||
const locale = new LocaleRuntime(runtime.ctx)
|
||||
runtime.ctx.provide('locale', locale)
|
||||
runtime.slots.installLocale(locale)
|
||||
if (options.ungroupedSession !== undefined) {
|
||||
await runtime.sessions.add({
|
||||
id: options.ungroupedSession,
|
||||
summary: { title: 'Loose', displayTitle: 'Loose', cwd: '/elsewhere' },
|
||||
})
|
||||
}
|
||||
await runtime.workspaces.update((draft) => {
|
||||
draft.items = [{
|
||||
workspaceId: WORKSPACE, title: 'Project', path: '/projects/project',
|
||||
sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
}] as never
|
||||
})
|
||||
await runtime.root.declare(
|
||||
{ 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never,
|
||||
SidebarFrame as never,
|
||||
)
|
||||
await runtime.mount({ inject: [...inject], apply })
|
||||
return runtime
|
||||
}
|
||||
|
||||
describe('Workspace header actions under the page access snapshot', () => {
|
||||
it('offers New Session, the Workspace menu, and the drag handle only where the snapshot grants them', async () => {
|
||||
const access = new ClientAccessState({
|
||||
type: 'baseline',
|
||||
owner: false,
|
||||
grants: [{ resource: { kind: 'workspace', id: WORKSPACE }, actions: ['workspace/follow'] }],
|
||||
})
|
||||
const runtime = await bench(access)
|
||||
try {
|
||||
runtime.renderRoot()
|
||||
const header = await screen.findByRole('treeitem')
|
||||
expect(screen.queryByRole('button', { name: '在“Project”中新建会话' })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: '工作区“Project”的操作' })).toBeNull()
|
||||
expect(header.getAttribute('draggable')).toBe('false')
|
||||
|
||||
// The Workspace's own grant moves its actions; the global answer is never consulted for them.
|
||||
act(() => {
|
||||
access.update({
|
||||
type: 'upsert',
|
||||
grant: {
|
||||
resource: { kind: 'workspace', id: WORKSPACE },
|
||||
actions: ['session/create', 'workspace/follow', 'workspace/insertBefore', 'workspace/rename'],
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '在“Project”中新建会话' })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '工作区“Project”的操作' })).toBeTruthy()
|
||||
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('true')
|
||||
|
||||
// The owner's snapshot answers every action.
|
||||
act(() => { access.replace({ type: 'baseline', owner: true }) })
|
||||
expect(screen.getByRole('button', { name: '在“Project”中新建会话' })).toBeTruthy()
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('asks the global resource before the Ungrouped header offers New Session', async () => {
|
||||
const access = new ClientAccessState({ type: 'baseline', owner: false, grants: [] })
|
||||
const runtime = await bench(access, { ungroupedSession: 'loose' as SessionId })
|
||||
try {
|
||||
runtime.renderRoot()
|
||||
await screen.findByText('未分组')
|
||||
expect(screen.queryByRole('button', { name: '在“未分组”中新建会话' })).toBeNull()
|
||||
act(() => {
|
||||
access.update({ type: 'upsert', grant: { resource: { kind: 'global' }, actions: ['session/create'] } })
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '在“未分组”中新建会话' })).toBeTruthy()
|
||||
} finally {
|
||||
await runtime.dispose()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-l
|
||||
import type { ComponentProps } from 'react'
|
||||
import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { allowAllAccess, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
|
||||
import { HoverCardGroup, MenuItemButton, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -12,7 +12,7 @@ import type { MenuOpenState, SessionRowOwnerProps } from '../src/client/contract
|
||||
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
|
||||
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
|
||||
import {
|
||||
ProjectRowItem, SearchResultItem, SessionNodeItem as SessionNodeItemComponent,
|
||||
ProjectRowItem as ProjectRowItemComponent, SearchResultItem, SessionNodeItem as SessionNodeItemComponent,
|
||||
} from '../src/client/rows/Rows.tsx'
|
||||
import type { GroupNode, SearchResultNode, SessionNode } from '../src/client/tree.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
@@ -34,6 +34,7 @@ type RowSlotName =
|
||||
| 'sidebar.session.row.hover'
|
||||
type RowRenderSlot = PropsRenderSlots<RowSlotName>['renderSlot']
|
||||
type SessionNodeItemProps = ComponentProps<typeof SessionNodeItemComponent>
|
||||
type ProjectRowItemProps = ComponentProps<typeof ProjectRowItemComponent>
|
||||
|
||||
const renderNoRowEntries: RowRenderSlot = () => null
|
||||
|
||||
@@ -48,10 +49,24 @@ function realDoubleClick(el: Element): void {
|
||||
fireEvent.doubleClick(el)
|
||||
}
|
||||
|
||||
function SessionNodeItem({ renderSlot = renderNoRowEntries, onRenameRequest = () => {}, ...props }: Omit<
|
||||
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest'
|
||||
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest'>>) {
|
||||
return <SessionNodeItemComponent {...props} renderSlot={renderSlot} onRenameRequest={onRenameRequest} />
|
||||
function SessionNodeItem({
|
||||
renderSlot = renderNoRowEntries, onRenameRequest = () => {}, useSessionAccess = allowAllAccess.useSessionAccess, ...props
|
||||
}: Omit<
|
||||
SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'useSessionAccess'
|
||||
> & Partial<Pick<SessionNodeItemProps, 'renderSlot' | 'onRenameRequest' | 'useSessionAccess'>>) {
|
||||
return (
|
||||
<SessionNodeItemComponent
|
||||
{...props} renderSlot={renderSlot} onRenameRequest={onRenameRequest} useSessionAccess={useSessionAccess}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Direct header specs hold every access answer unless the case refuses one.
|
||||
function ProjectRowItem({
|
||||
useRemoteAccess = allowAllAccess.useRemoteAccess, useWorkspaceAccess = allowAllAccess.useWorkspaceAccess, ...props
|
||||
}: Omit<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess'>
|
||||
& Partial<Pick<ProjectRowItemProps, 'useRemoteAccess' | 'useWorkspaceAccess'>>) {
|
||||
return <ProjectRowItemComponent {...props} useRemoteAccess={useRemoteAccess} useWorkspaceAccess={useWorkspaceAccess} />
|
||||
}
|
||||
|
||||
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
|
||||
@@ -273,7 +288,8 @@ describe('workspace browser rows', () => {
|
||||
archived: false,
|
||||
snippet: 'matching message excerpt',
|
||||
}
|
||||
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen} onUnarchive={vi.fn()} t={t} />)
|
||||
render(<SearchResultItem result={result} currentId={result.id} onOpen={onOpen}
|
||||
onUnarchive={vi.fn()} useSessionAccess={allowAllAccess.useSessionAccess} t={t} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.getAttribute('aria-selected')).toBe('true')
|
||||
expect(screen.getByText('Workspace context')).toBeTruthy()
|
||||
@@ -285,6 +301,24 @@ describe('workspace browser rows', () => {
|
||||
expect(onOpen).toHaveBeenCalledWith(result.id)
|
||||
})
|
||||
|
||||
it('withholds the search-result restore control while the Session access excludes unarchiving', () => {
|
||||
const result: SearchResultNode = {
|
||||
id: sid('archived-result'), title: 'Archived', workspace: 'Project',
|
||||
running: false, runningSubagentCount: 0, completed: false, archived: true,
|
||||
}
|
||||
const denied = vi.fn(() => false)
|
||||
const view = render(<SearchResultItem result={result} currentId={undefined}
|
||||
onOpen={vi.fn()} onUnarchive={vi.fn()} useSessionAccess={denied} t={t} />)
|
||||
expect(denied).toHaveBeenCalledWith(result.id, 'workspace/unarchiveSession')
|
||||
expect(view.queryByRole('button', { name: zh['menu.unarchiveSession'] })).toBeNull()
|
||||
cleanup()
|
||||
// The allowed answer renders the same verb.
|
||||
const allowed = vi.fn(() => true)
|
||||
const restored = render(<SearchResultItem result={result} currentId={undefined}
|
||||
onOpen={vi.fn()} onUnarchive={vi.fn()} useSessionAccess={allowed} t={t} />)
|
||||
expect(restored.getByRole('button', { name: zh['menu.unarchiveSession'] })).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['approval', '等待审批'],
|
||||
['plan-review', '计划待审'],
|
||||
@@ -294,7 +328,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid(pendingInteraction), title: 'Needs input', workspace: 'Project',
|
||||
pendingInteraction, running: true, runningSubagentCount: 0, completed: false, archived: false,
|
||||
}
|
||||
render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} onUnarchive={vi.fn()} t={t} />)
|
||||
render(<SearchResultItem result={result} currentId={undefined}
|
||||
onOpen={vi.fn()} onUnarchive={vi.fn()} useSessionAccess={allowAllAccess.useSessionAccess} t={t} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.querySelector('[data-state="warning"]')).toBeTruthy()
|
||||
expect(row.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
@@ -582,7 +617,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('result'), title: 'Done', workspace: 'Workspace', running: false,
|
||||
runningSubagentCount: 0, completed: true, archived: false,
|
||||
}}
|
||||
currentId={undefined} onOpen={vi.fn()} onUnarchive={vi.fn()} t={t}
|
||||
currentId={undefined} onOpen={vi.fn()} onUnarchive={vi.fn()}
|
||||
useSessionAccess={allowAllAccess.useSessionAccess} t={t}
|
||||
/>)
|
||||
expect(screen.getByRole('treeitem').querySelector('[data-state="done"]')).not.toBeNull()
|
||||
})
|
||||
@@ -617,6 +653,82 @@ describe('workspace browser rows', () => {
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('workspace row offers only the actions its Workspace access snapshot allows', () => {
|
||||
const group: GroupNode = {
|
||||
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', createdAt: 0, label: 'Project',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
const allowed = new Set<string>(['workspace/rename'])
|
||||
const useWorkspaceAccess = vi.fn((_workspaceId: WorkspaceId, endpoint: string) => allowed.has(endpoint))
|
||||
const drag = { start: vi.fn(), end: vi.fn() }
|
||||
const props = {
|
||||
group, onToggle: vi.fn(), onCreate: vi.fn(), actions: { rename: vi.fn(), delete: vi.fn() }, drag, t,
|
||||
}
|
||||
const view = render(<ProjectRowItem {...props} useWorkspaceAccess={useWorkspaceAccess} />)
|
||||
expect(useWorkspaceAccess).toHaveBeenCalledWith(wid('project'), 'session/create')
|
||||
// No New Session button, no drag handle, and a menu holding only the allowed rename row.
|
||||
expect(screen.queryByRole('button', { name: '在“Project”中新建会话' })).toBeNull()
|
||||
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('false')
|
||||
fireEvent.click(screen.getByRole('button', { name: '工作区“Project”的操作' }))
|
||||
expect(screen.getByRole('menuitem', { name: '重命名' })).toBeTruthy()
|
||||
expect(screen.queryByRole('menuitem', { name: '删除工作区' })).toBeNull()
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
|
||||
// Without rename either, the header carries no menu at all.
|
||||
allowed.clear()
|
||||
view.rerender(<ProjectRowItem {...props} useWorkspaceAccess={useWorkspaceAccess} />)
|
||||
expect(screen.queryByRole('button', { name: '工作区“Project”的操作' })).toBeNull()
|
||||
|
||||
// Every answer allowed restores the button, the full menu, and the drag handle.
|
||||
for (const endpoint of ['session/create', 'workspace/rename', 'workspace/delete', 'workspace/insertBefore']) {
|
||||
allowed.add(endpoint)
|
||||
}
|
||||
view.rerender(<ProjectRowItem {...props} useWorkspaceAccess={useWorkspaceAccess} />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('true')
|
||||
fireEvent.click(screen.getByRole('button', { name: '在“Project”中新建会话' }))
|
||||
expect(props.onCreate).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('ungrouped bucket asks the global resource whether it may start a Session', () => {
|
||||
const group: GroupNode = {
|
||||
key: 'ungrouped', workspaceId: undefined, cwd: undefined, createdAt: undefined, label: '',
|
||||
sessionCount: 0, expanded: false, containsCurrent: false, sessions: [],
|
||||
}
|
||||
const useRemoteAccess = vi.fn((action: string) => action !== 'session/create')
|
||||
const useWorkspaceAccess = vi.fn(() => true)
|
||||
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} t={t}
|
||||
useRemoteAccess={useRemoteAccess} useWorkspaceAccess={useWorkspaceAccess} />)
|
||||
expect(useRemoteAccess).toHaveBeenCalledWith('session/create')
|
||||
expect(useWorkspaceAccess).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('button', { name: /新建会话/ })).toBeNull()
|
||||
})
|
||||
|
||||
it('session row drags and renames from its title only while its access snapshot allows each', () => {
|
||||
const node: SessionNode = {
|
||||
id: sid('s1'), title: 'One', blank: false, running: false,
|
||||
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: false,
|
||||
}
|
||||
const drag: RowDragProps = {
|
||||
active: false, marker: null, start: vi.fn(), end: vi.fn(), hover: vi.fn(), drop: vi.fn(),
|
||||
}
|
||||
const onRenameRequest = vi.fn()
|
||||
const useSessionAccess = vi.fn((_sessionId: SessionId, _endpoint: string) => false)
|
||||
const view = render(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag}
|
||||
onRenameRequest={onRenameRequest} useSessionAccess={useSessionAccess} t={t} />)
|
||||
expect(useSessionAccess).toHaveBeenCalledWith(sid('s1'), 'session/rename')
|
||||
expect(useSessionAccess).toHaveBeenCalledWith(sid('s1'), 'workspace/insertSessionBefore')
|
||||
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('false')
|
||||
fireEvent.doubleClick(screen.getByText('One'))
|
||||
expect(onRenameRequest).not.toHaveBeenCalled()
|
||||
|
||||
view.rerender(<SessionNodeItem node={node} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag}
|
||||
onRenameRequest={onRenameRequest} useSessionAccess={() => true} t={t} />)
|
||||
expect(screen.getByRole('treeitem').getAttribute('draggable')).toBe('true')
|
||||
// A browser rename arms on the first press and fires on the double-click; the helper states both.
|
||||
realDoubleClick(screen.getByText('One'))
|
||||
expect(onRenameRequest).toHaveBeenCalledWith(sid('s1'), 'One')
|
||||
})
|
||||
|
||||
it('workspace hover card shows its details and copies the full directory path', async () => {
|
||||
vi.useFakeTimers()
|
||||
const writeText = vi.fn(async () => {})
|
||||
@@ -1288,7 +1400,8 @@ describe('workspace browser rows', () => {
|
||||
id: sid('result'), title: 'Old result', workspace: 'Workspace context',
|
||||
running: false, runningSubagentCount: 0, completed: false, archived: true,
|
||||
}
|
||||
render(<SearchResultItem result={result} currentId={undefined} onOpen={vi.fn()} onUnarchive={vi.fn()} t={t} />)
|
||||
render(<SearchResultItem result={result} currentId={undefined}
|
||||
onOpen={vi.fn()} onUnarchive={vi.fn()} useSessionAccess={allowAllAccess.useSessionAccess} t={t} />)
|
||||
const row = screen.getByRole('treeitem')
|
||||
expect(row.className).toMatch(/archived/)
|
||||
// No leading marker: the grayed row alone carries the archived look.
|
||||
@@ -1459,7 +1572,7 @@ it.each([['未命名', t], ['Untitled', tEn]])('labels unnamed search results as
|
||||
running: false, runningSubagentCount: 0, completed: false, archived: false }
|
||||
const onOpen = vi.fn()
|
||||
render(<SearchResultItem result={result} currentId={undefined} onOpen={onOpen}
|
||||
onUnarchive={vi.fn()} t={translate} />)
|
||||
onUnarchive={vi.fn()} useSessionAccess={allowAllAccess.useSessionAccess} t={translate} />)
|
||||
fireEvent.click(screen.getByText(label))
|
||||
expect(onOpen).toHaveBeenCalledWith(result.id)
|
||||
})
|
||||
@@ -171,6 +171,22 @@ describe('pin action', () => {
|
||||
expect(pin.pinSession).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('offers neither toggle while the access snapshot excludes it for this Session', () => {
|
||||
const { state } = openMenu()
|
||||
const useSessionAccess = vi.fn((_id: SessionId, endpoint: string) => endpoint !== 'workspace/pinSession')
|
||||
const menu = render(<PinSessionMenuItem {...menuRow(state)} {...pinShare()} useSessionAccess={useSessionAccess} />)
|
||||
expect(menu.container.innerHTML).toBe('')
|
||||
expect(useSessionAccess).toHaveBeenCalledWith(sid('one'), 'workspace/pinSession')
|
||||
menu.unmount()
|
||||
const button = render(<PinSessionRowButton {...actionRow} {...pinShare()} useSessionAccess={useSessionAccess} />)
|
||||
expect(button.container.innerHTML).toBe('')
|
||||
button.unmount()
|
||||
// A pinned row asks for its unpin endpoint instead.
|
||||
const pinned = render(<PinSessionRowButton {...actionRow} {...pinShare(['one'])} useSessionAccess={useSessionAccess} />)
|
||||
expect(useSessionAccess).toHaveBeenLastCalledWith(sid('one'), 'workspace/unpinSession')
|
||||
expect(pinned.getByRole('button', { name: '取消置顶' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers nothing on an archived Session', () => {
|
||||
const { state } = openMenu()
|
||||
const pin = pinShare(['one'], ['one'])
|
||||
@@ -215,6 +231,22 @@ describe('archive action', () => {
|
||||
expect(callOrder(setMenuOpen)).toBeLessThan(callOrder(archive.unarchiveSession))
|
||||
})
|
||||
|
||||
it('offers neither the archive row nor its button while the access snapshot excludes archiving', () => {
|
||||
const { state } = openMenu()
|
||||
const useSessionAccess = vi.fn((_id: SessionId, endpoint: string) => endpoint !== 'workspace/archiveSession')
|
||||
const menu = render(<ArchiveSessionMenuItem {...menuRow(state)} {...archiveShare()} useSessionAccess={useSessionAccess} />)
|
||||
expect(menu.container.innerHTML).toBe('')
|
||||
expect(useSessionAccess).toHaveBeenCalledWith(sid('one'), 'workspace/archiveSession')
|
||||
menu.unmount()
|
||||
const button = render(<ArchiveSessionRowButton {...actionRow} {...archiveShare()} useSessionAccess={useSessionAccess} />)
|
||||
expect(button.container.innerHTML).toBe('')
|
||||
button.unmount()
|
||||
// An archived row asks for its restore endpoint instead.
|
||||
const archived = render(<ArchiveSessionRowButton {...actionRow} {...archiveShare(['one'])} useSessionAccess={useSessionAccess} />)
|
||||
expect(useSessionAccess).toHaveBeenLastCalledWith(sid('one'), 'workspace/unarchiveSession')
|
||||
expect(archived.getByRole('button')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('row button archives and restores', () => {
|
||||
const archive = archiveShare()
|
||||
const view = render(<ArchiveSessionRowButton {...actionRow} {...archive} />)
|
||||
@@ -248,6 +280,16 @@ describe('fork and rename rows', () => {
|
||||
expect(useSessionAccess).toHaveBeenCalledWith(sid('one'), 'session/fork')
|
||||
})
|
||||
|
||||
it('omits the rename row when the access snapshot excludes session/rename for this Session', () => {
|
||||
const { state } = openMenu()
|
||||
const useSessionAccess = vi.fn(() => false)
|
||||
const view = render(
|
||||
<RenameSessionMenuItem {...menuRow(state)} useSessionAccess={useSessionAccess} requestSessionRename={vi.fn()} />,
|
||||
)
|
||||
expect(view.container.innerHTML).toBe('')
|
||||
expect(useSessionAccess).toHaveBeenCalledWith(sid('one'), 'session/rename')
|
||||
})
|
||||
|
||||
it('rename closes the menu, then asks for the dialog with the row title', () => {
|
||||
const { state, setMenuOpen } = openMenu()
|
||||
const requestSessionRename = vi.fn()
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionListState, SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type {
|
||||
SessionListState, SessionProjectionMap, SessionSummary,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
|
||||
import type {
|
||||
SessionPendingInteraction, SessionStatus, SessionStatusSnapshot,
|
||||
} from '@deepseek-ai/dsh-client-ui-session/client'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { JsonValue } from '@deepseek-ai/dsh-util-values'
|
||||
import type { SessionProjectionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client'
|
||||
import {
|
||||
type ArchivedFilter,
|
||||
@@ -39,6 +42,38 @@ const withMain = (state: SessionListState, id: SessionId): SessionListState => (
|
||||
[id]: { ...state.byId[id]!, retainedBy: { ...state.byId[id]!.retainedBy, mainView: 1 } },
|
||||
},
|
||||
})
|
||||
/**
|
||||
* Projection values one shipped Host publishes for a Session. The `forkLineage` key belongs to the
|
||||
* Liveshare package, which this program does not name, so the values are built through the open key
|
||||
* space the wire allows.
|
||||
*/
|
||||
const projectionValues = (values: Record<string, JsonValue>): Readonly<Partial<SessionProjectionMap>> => values
|
||||
/**
|
||||
* One list where the named Session carries the branch facts the Host publishes for this reader.
|
||||
* @param items - list rows.
|
||||
* @param branchId - Session the facts belong to.
|
||||
* @param lineage - the branch fields that case exercises; `origin` is always a fork.
|
||||
* @returns the list with that Session's `forkLineage` projection value.
|
||||
*/
|
||||
const branchedList = (
|
||||
items: readonly SessionSummary[],
|
||||
branchId: SessionId,
|
||||
lineage: { readonly sourceSessionId: SessionId; readonly workspaceReadOnly?: boolean },
|
||||
): SessionListState => {
|
||||
const base = list(...items)
|
||||
return {
|
||||
...base,
|
||||
byId: {
|
||||
...base.byId,
|
||||
[branchId]: {
|
||||
...base.byId[branchId]!,
|
||||
projectionValues: projectionValues({
|
||||
forkLineage: { origin: 'fork', authoredByViewer: true, workspaceReadOnly: true, ...lineage },
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
|
||||
workspaceId: wid(id), path: `/projects/${id}`, title,
|
||||
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
|
||||
@@ -544,6 +579,17 @@ describe('deriveFlat', () => {
|
||||
expect(visibleSessionIds(partial, noArchive, 'default')).toEqual([sid('present')])
|
||||
})
|
||||
|
||||
it('leaves plugin-owned lineage interpretation to row slots in both list modes', () => {
|
||||
const source = summary('shared', 1)
|
||||
const child = summary('my-branch', 2)
|
||||
const branched = branchedList([source, child], child.id, { sourceSessionId: source.id })
|
||||
const rows = deriveFlat(branched, [source.id, child.id], noRows, noAttention)
|
||||
expect(rows.every(row => !('fork' in row))).toBe(true)
|
||||
const groups = deriveGroups(branched, [workspace('first', ['shared', 'my-branch'])],
|
||||
noRows, noAttention, view(['first']))
|
||||
expect(groups[0]!.sessions.every(row => !('fork' in row))).toBe(true)
|
||||
})
|
||||
|
||||
it('shows only the current blank session and excludes blanks from search', () => {
|
||||
const currentBlank = { ...summary('current-blank', 9), blank: true, retainedBy: { mainView: 1 } }
|
||||
const staleBlank = { ...summary('stale-blank', 8), blank: true }
|
||||
@@ -623,6 +669,26 @@ describe('deriveSearchResults archive filtering', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults branch marks', () => {
|
||||
it('carries the same branch facts a matching row shows in the list', () => {
|
||||
const source = summary('shared', 1)
|
||||
source.title = source.displayTitle = 'Needle source'
|
||||
const branch = summary('my-branch', 2)
|
||||
branch.title = branch.displayTitle = 'Needle branch'
|
||||
const result = deriveSearchResults(
|
||||
branchedList([source, branch], branch.id, { sourceSessionId: source.id }),
|
||||
[workspace('first', ['shared', 'my-branch'])],
|
||||
'needle',
|
||||
noArchive,
|
||||
'default',
|
||||
noAttention,
|
||||
{ items: [], hasMore: false },
|
||||
10,
|
||||
)
|
||||
expect(result.items.every(item => !('fork' in item))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deriveSearchResults', () => {
|
||||
it('merges local title/Workspace matches before ranked content hits and enriches duplicates', () => {
|
||||
const titleHit = summary('title-hit', 30, '/projects/a')
|
||||
|
||||
@@ -355,7 +355,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
signature: 'connectWorkspace(workspaceId: WorkspaceId, options?: Pick<StartSessionOptions, \'agentPreset\'>): Promise<SessionId>',
|
||||
description: 'Resolve the Session for a new conversation in a Workspace: navigation names the first reusable blank member, skipping blanks that list a preset other than a requested one, and the Host reuses it under the requested preset or its default, or creates another Session.',
|
||||
description: 'Resolve the Session for a new conversation in a Workspace: navigation names the first reusable blank member — listed in the Workspace, not archived, and listing no preset or the requested one — and the Host reuses it under the requested preset or its default, creating another Session when it reuses none. Concurrent calls for the same Workspace and preset share one attempt.',
|
||||
parameters: [{ name: 'workspaceId', description: 'target Workspace.' }, { name: 'options', description: 'choices for the created or reused Session.' }],
|
||||
returns: 'a Session already addressable through the Session Controller.',
|
||||
},
|
||||
|
||||
@@ -1912,7 +1912,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
|
||||
},
|
||||
],
|
||||
ownerProps: [
|
||||
'/** Owner share of the View tab row\'s More menu rows. */\nexport interface ConversationViewActionsOwnerProps {\n /** Available Views in registration order; Chat is never listed. */\n readonly entries: readonly ConversationViewActionEntry[]\n /**\n * Select a View in this Session without changing its pin state.\n * @param id - Listed View id.\n */\n readonly onOpen: (id: string) => void\n /**\n * Show (true) or unpin (false) a View tab in every Session.\n * @param id - Listed View id.\n * @param pinned - Whether the tab shows in the header.\n */\n readonly onPinnedChange: (id: string, pinned: boolean) => void\n /** Close the More menu. */\n readonly onDismiss: () => void\n}',
|
||||
'/** Owner share of the View tab row\'s More menu rows. */\nexport interface ConversationViewActionsOwnerProps {\n /** Views outside the header in registration order. */\n readonly entries: readonly ConversationViewActionEntry[]\n /**\n * Select a View in this Session without changing its pin state.\n * @param id - Listed View id.\n */\n readonly onOpen: (id: string) => void\n /**\n * Pin or unpin a View in every Session.\n * @param id - Listed View id.\n * @param pinned - Whether the View is manually pinned; the width budget can still fold it into More.\n */\n readonly onPinnedChange: (id: string, pinned: boolean) => void\n /** Close the More menu. */\n readonly onDismiss: () => void\n}',
|
||||
],
|
||||
ownerPropsReferences: [
|
||||
'ConversationViewActionEntry',
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('/permission command', () => {
|
||||
expect(inject.mock.calls[0]?.[0]).toMatchObject({
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: 'The approval policy changed from "ask" to "never" (changed by the user).',
|
||||
text: 'The user changed the approval policy from "ask the user for approval" to "never ask the user for approval".',
|
||||
}],
|
||||
})
|
||||
const run = session.snapshotEvents().find(event => event.type === 'command/run')
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
en: 3618ddc3386e65f0
|
||||
zh: d6c0ac91aae122bb
|
||||
/deepseek-ai-dsh-user-approval/use-this-package/setting-the-policy:
|
||||
en: d4ef1901ac78b431
|
||||
zh: 822b97f8bed1b2c9
|
||||
en: 79f3334a395b9368
|
||||
zh: 0c4026b505ee254a
|
||||
/deepseek-ai-dsh-user-approval/use-this-package/requesting-a-decision:
|
||||
en: dc465de82799bab0
|
||||
zh: 51144cc77bb15689
|
||||
/deepseek-ai-dsh-user-approval/use-this-package/what-the-model-and-user-see:
|
||||
en: 016bf3cd1891d3d7
|
||||
zh: 33e7e5ffaf95f5e7
|
||||
en: 56e57ef3cac09a2a
|
||||
zh: 8f1faadcbba500e3
|
||||
/deepseek-ai-dsh-user-approval/understand-the-implementation:
|
||||
en: d07be7ba08baf010
|
||||
zh: 665e68e9188e5d60
|
||||
@@ -54,14 +54,17 @@
|
||||
en: d23980f003b42fbb
|
||||
zh: 532f0174de5d87b5
|
||||
/deepseek-ai-dsh-user-approval/model-experience/current-approval-policy-context/what-the-model-sees:
|
||||
en: a38775858ccd61f3
|
||||
zh: c19c76827be7aabf
|
||||
en: eb41f1a4972c07f5
|
||||
zh: 4264f408eb951951
|
||||
/deepseek-ai-dsh-user-approval/model-experience/current-approval-policy-context/what-the-model-sees/ask-policy-contribution:
|
||||
en: b9f1262c97ec7568
|
||||
zh: 8a6613695f896f70
|
||||
/deepseek-ai-dsh-user-approval/model-experience/current-approval-policy-context/what-the-model-sees/never-policy-contribution:
|
||||
en: f13f09fc2466513b
|
||||
zh: 4a755a682551c481
|
||||
/deepseek-ai-dsh-user-approval/model-experience/current-approval-policy-context/what-the-model-sees/switch-notice:
|
||||
en: e87e1e28ae7d7243
|
||||
zh: c6daddb5a131565a
|
||||
/deepseek-ai-dsh-user-approval/model-experience/current-approval-policy-context/token-effect:
|
||||
en: e96807422bdce44d
|
||||
zh: 9fdcfafcc96bd004
|
||||
|
||||
@@ -46,7 +46,7 @@ The effective policy is the one set for the session, falling back to the configu
|
||||
|---|---|---|
|
||||
| `policy` | `ask` | Default for sessions without an `approval/policy` override |
|
||||
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-user-approval) is the exhaustive source for every accepted field and its JSDoc. `setPolicy(agent, policy)` switches a live agent and queues a "changed by the user" message for its next model step; `setApprovalPolicy(session, policy)` is the direct durable write path used by session initialization.
|
||||
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-user-approval) is the exhaustive source for every accepted field and its JSDoc. `setPolicy(agent, policy)` switches a live agent and queues the switch notice described under Model Experience for its next model step; `setApprovalPolicy(session, policy)` is the direct durable write path used by session initialization.
|
||||
|
||||
### Requesting a decision
|
||||
|
||||
@@ -54,7 +54,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a
|
||||
|
||||
### What the model and user see
|
||||
|
||||
The model sees only the asking consumer's eventual tool outcome — allowed, rejected, cancelled, or unavailable — plus the current policy in the runtime-context snapshot; the audit events and the human permission UI are not model context. A `never` switch is announced to the model by a sourced user message, and both policies contribute their complete current meaning to the snapshot.
|
||||
The model sees only the asking consumer's eventual tool outcome — allowed, rejected, cancelled, or unavailable — plus the current policy in the runtime-context snapshot; the audit events and the human permission UI are not model context. Each policy switch is announced to the model by a sourced user message, and both policies contribute their complete current meaning to the snapshot.
|
||||
|
||||
-----
|
||||
|
||||
@@ -112,12 +112,12 @@ Read these pages when the package-level contract is not enough. They move from t
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message.
|
||||
The first request and each effective policy change append a full runtime-context snapshot after retained history. Under `ask`, the approval contribution states that configured answerers may be consulted and absence fails closed. Under `never`, it states the deterministic rejection and non-escalation consequence. Unchanged requests retain the earlier snapshot without adding another message. When the user switches a live Session's policy, the next model step also receives a switch notice.
|
||||
|
||||
##### Ask-policy contribution
|
||||
|
||||
```markdown
|
||||
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
|
||||
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.
|
||||
```
|
||||
|
||||
##### Never-policy contribution
|
||||
@@ -126,6 +126,12 @@ Approval policy: ask. Operations that require approval may ask through the confi
|
||||
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
|
||||
```
|
||||
|
||||
##### Switch notice
|
||||
|
||||
```markdown
|
||||
The user changed the approval policy from "ask the user for approval" to "never ask the user for approval".
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
One concise context message on the first request and on an effective change; unchanged requests add no duplicate policy tokens.
|
||||
|
||||
@@ -46,7 +46,7 @@ kind: "package-reference"
|
||||
|---|---|---|
|
||||
| `policy` | `ask` | 没有 `approval/policy` 覆盖的会话的默认策略 |
|
||||
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-user-approval)是每个受支持字段及其 JSDoc 的穷尽式真源。`setPolicy(agent, policy)` 切换运行中的 agent 的策略,并为它的下一个模型步骤排队一条「由用户更改」消息;`setApprovalPolicy(session, policy)` 是会话初始化使用的直接持久写入路径。
|
||||
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-user-approval)是每个受支持字段及其 JSDoc 的穷尽式真源。`setPolicy(agent, policy)` 切换运行中的 agent 的策略,并为它的下一个模型步骤排队一条模型体验一节所述的切换通知;`setApprovalPolicy(session, policy)` 是会话初始化使用的直接持久写入路径。
|
||||
|
||||
### 请求决定
|
||||
|
||||
@@ -54,7 +54,7 @@ kind: "package-reference"
|
||||
|
||||
### 模型与用户看到什么
|
||||
|
||||
模型只会看到发起请求的消费方最终给出的工具结果——允许、拒绝、取消或不可用——以及运行时上下文快照中的当前策略;审计事件与面向人类的权限 UI 不属于模型上下文。`never` 切换会以一条带来源的用户消息告知模型,两种策略都会把各自的完整当前含义贡献给快照。
|
||||
模型只会看到发起请求的消费方最终给出的工具结果——允许、拒绝、取消或不可用——以及运行时上下文快照中的当前策略;审计事件与面向人类的权限 UI 不属于模型上下文。每次策略切换都会以一条带来源的用户消息告知模型,两种策略都会把各自的完整当前含义贡献给快照。
|
||||
|
||||
-----
|
||||
|
||||
@@ -112,12 +112,12 @@ kind: "package-reference"
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
首次请求与有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,审批上下文内容会说明系统可以咨询已配置的应答者,缺少可用应答者时则以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。
|
||||
首次请求与有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,审批上下文内容会说明系统可以咨询已配置的应答者,缺少可用应答者时则以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。用户切换运行中 Session 的策略时,下一次模型调用还会收到一条切换通知。
|
||||
|
||||
##### Ask 策略贡献
|
||||
|
||||
```markdown
|
||||
Approval policy: ask. Operations that require approval may ask through the configured answerers; without an available answerer, the request fails closed.
|
||||
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.
|
||||
```
|
||||
|
||||
##### Never 策略贡献
|
||||
@@ -126,6 +126,12 @@ Approval policy: ask. Operations that require approval may ask through the confi
|
||||
Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).
|
||||
```
|
||||
|
||||
##### 切换通知
|
||||
|
||||
```markdown
|
||||
The user changed the approval policy from "ask the user for approval" to "never ask the user for approval".
|
||||
```
|
||||
|
||||
#### Token 影响
|
||||
|
||||
首次请求和策略实际变化时增加一条简洁的上下文消息;未变化的请求不增加重复的策略 token。
|
||||
|
||||
Loaded 100 of 208 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user