Merge branch 'master' into feat/produced-files-folder
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/README.md
|
||||
README.md: eb7df95bde10dafd7afcb168d30c9dda90296687
|
||||
README.md: 257ed4647c74ef45973d717278cdaa420968b3f6
|
||||
README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a
|
||||
|
||||
@@ -10,48 +10,48 @@ Groups hold `packages/<group>/<pkg>/`; names stay `@deepseek-ai/dsh-<pkg>`. **Gr
|
||||
|
||||
| Group | Role | Release expectation |
|
||||
|---|---|---|
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable surface |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable surface |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable surface |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable surface |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable surface |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
|
||||
| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable API |
|
||||
| [`api/`](api/README.md) | Remote BFF assembly and TypeRT RPC gateway | Product — stable API |
|
||||
| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable API |
|
||||
| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable API |
|
||||
| [`feedback/`](feedback/README.md) | Human feedback | Product — stable API |
|
||||
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API |
|
||||
| [`e2b/`](e2b/README.md) | E2B providers | POC |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable surface |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable surface |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable surface |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable surface |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable surface |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable surface |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable surface |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable surface |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable surface |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable surface |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable surface |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface |
|
||||
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
|
||||
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable surface |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface |
|
||||
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface |
|
||||
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable surface |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable surface |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable surface |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable surface |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable surface |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable surface |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable surface |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface |
|
||||
| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable API |
|
||||
| [`bash/`](bash/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API |
|
||||
| [`pty/`](pty/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API |
|
||||
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API |
|
||||
| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable API |
|
||||
| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable API |
|
||||
| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable API |
|
||||
| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable API |
|
||||
| [`compact/`](compact/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable API |
|
||||
| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable API |
|
||||
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable API |
|
||||
| [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable API |
|
||||
| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable API |
|
||||
| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable API |
|
||||
| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable API |
|
||||
| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable API |
|
||||
| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable API |
|
||||
| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable API |
|
||||
| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable API |
|
||||
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable API |
|
||||
| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable API |
|
||||
| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable API |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable API |
|
||||
| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable API |
|
||||
| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API |
|
||||
| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API |
|
||||
| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API |
|
||||
| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API |
|
||||
| [`scaffold/`](scaffold/README.md) | Create/launch/drive project tooling: helper, launcher, initializer, wire protocol with both ends, launcher telemetry | Product — stable API |
|
||||
| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API |
|
||||
| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API |
|
||||
| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API |
|
||||
| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable API |
|
||||
| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable API |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra |
|
||||
| [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations |
|
||||
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded<B>`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free |
|
||||
|
||||
@@ -252,6 +252,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
assertOpen()
|
||||
validateSessionParams(params)
|
||||
const sessionId = SessionId(randomUUID())
|
||||
// No preset composition: the ACP bundle keeps the model-facing rows in
|
||||
// the host plane, so this agent reads them from the global layer. A
|
||||
// deployment that configures a roster has to join one here first
|
||||
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
||||
const handle = await agents.create({
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/api/remotes/README.md
|
||||
README.md: 3d9de0955faefe37c95ff8bb792d57c4fa1f1a3a
|
||||
README.md: 567ece0fb58d4b9c0b022dd2ce4d8ee87caccc57
|
||||
README.zh.md: 12add6f8efc5b6af3e9b74b26a1abb2bb3936e0a
|
||||
|
||||
@@ -20,7 +20,7 @@ The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bun
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this BFF selects Remote application methods and identity policy but registers no model surface.
|
||||
None, as this BFF selects Remote application methods and identity policy but registers nothing model-facing.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash-local/README.md
|
||||
README.md: cb8e7f0ae766d9b1c5f1678e77d35992085d3d52
|
||||
README.zh.md: 20af9c18998c6f3f0403c50f3a8ac599607dc094
|
||||
README.md: 253d1c9efb518ca204956f062b2f529f4e83531b
|
||||
README.zh.md: 5a0d943a08d1da6c73ad07f469c056180b476ff3
|
||||
|
||||
@@ -25,7 +25,7 @@ The package root exports the default and named `LocalBashExecutor` plugin plus i
|
||||
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` with no rc files.
|
||||
- **Configured budgets over managed groups** — `resolve()` fills `workdir`/`timeoutMs`/`stdoutMaxBytes` from config, and every spawn hands the service explicit byte caps, spill cap, and `graceMs`. The grace must be positive, finite, and no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), so Node can represent it with one timer. Process-group kills, post-exit pipe draining, tail retention, and bounded spill files are [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) mechanics. A foreground `BashExecRequest.stdoutMaxBytes` can raise stdout's capture budget for one trusted caller; stderr and background runs still use `maxOutputBytes`.
|
||||
- **Timeout and cancel classification** — `run()` fuses its config-clamped timeout with the caller's signal through one deadline; only the executor's own timeout reports `timedOut`, an upstream cancel reports `aborted`, and a self-signaled command reports neither ([timeout-library Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md)).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Model-friendly terminal env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` prevents pagers and ANSI color from garbling results. These values merge as ordinary env under the service's credential scrub and `DSH_*` channel rules; an explicit caller entry still wins. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [managed environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
- **Background processes** — `start()` returns a live `BashProcess` handle immediately with no timeout, and `readOutput()` merges offset-based stdout/stderr reads into one consuming delta, placing stderr under a `[stderr]` marker when present. A running process belongs to the subprocess service, survives executor reloads, and is killed and joined on service disposal. Task ids, ownership, polling, and notices belong to the generic [`ctx.tasks` runtime](../../tasks/tasks/README.md), which the tool layer registers the handle with.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
- **每次调用都 spawn,不保留 shell 状态**:每次调用都启动新的非登录 `bash -c`,且不读取 rc 文件。
|
||||
- **在受管进程组之上应用配置预算**:`resolve()` 从配置补全 `workdir`/`timeoutMs`/`stdoutMaxBytes`,每次 spawn 都向服务传入显式的字节上限、spill 上限与 `graceMs`。该宽限期须为正有限值,且不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md),这样 Node 就能用一个定时器表示它。进程组终止、退出后管道排空、尾部保留与有界 spill 文件是 [`dsh-subprocess-local`](../../subprocess/subprocess-local/README.md) 的机制。前台 `BashExecRequest.stdoutMaxBytes` 可为某个受信任调用方提高单次 stdout 捕获预算;stderr 和后台运行仍使用 `maxOutputBytes`。
|
||||
- **超时与取消分类**:`run()` 通过同一个 deadline 把经配置钳位的超时与调用方的信号融合;只有执行器自身的超时报告 `timedOut`,上游取消报告 `aborted`,自身因信号终止的命令两者皆不报告(见[超时库 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md))。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **适合模型的终端环境**:`NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` 防止分页器与 ANSI 颜色破坏结果。这些值作为普通 env 合并,遵循服务的凭据清除与 `DSH_*` 通道规则;调用方的显式条目依旧优先。详见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [受管环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
- **后台进程**:`start()` 会立即返回活动的 `BashProcess` 句柄且不应用超时;`readOutput()` 把基于偏移量的 stdout/stderr 读取合并为一条消费式增量,并在存在 stderr 时将其置于 `[stderr]` 标记下。运行中的进程属于 subprocess 服务,可在执行器重载后存活,并在服务 dispose 时被终止且等待退出。task id、所有权、轮询和通知属于通用 [`ctx.tasks` 运行时](../../tasks/tasks/README.md),工具层会在其中注册该句柄。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -307,7 +307,7 @@ describe('process lifecycle ownership (the subprocess service, not the executor)
|
||||
const bash = ctx.bash as LocalBashExecutor
|
||||
|
||||
// The child prints its own pid ($$ = the detached bash group leader) so
|
||||
// the test can probe liveness through the public read surface alone.
|
||||
// the test can probe liveness through the public read API alone.
|
||||
const proc = bash.start(bash.resolve({ command: 'echo $$; sleep 60' }))
|
||||
const pid = Number((await readUntil(proc, '\n')).trim())
|
||||
expect(Number.isInteger(pid) && pid > 0).toBe(true)
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/bash/README.md
|
||||
README.md: 23b0acd096bb835ef57563337c91e5cf63b58677
|
||||
README.zh.md: 14ba749a0018bd6a63475bc0ab72c6fe6d26893a
|
||||
README.md: a076c6ef3150de86c251f0501b18de01c4491c67
|
||||
README.zh.md: 1be9f817a8379b2974fb4cfd63c197c320934f00
|
||||
|
||||
@@ -33,7 +33,7 @@ Implementations subclass `BashExecutor` and implement the abstract methods. Disp
|
||||
|
||||
The per-session sandbox-mode override vocabulary (the `'sandbox/mode'` event, the `effectiveSandboxMode(events)` fold, and the `setSandboxMode(session, mode)` write path) is NOT here — it is policy state shared by every enforcing family, owned by [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/). `run()` returns `BashRunResult`; `start()` returns `BashProcess`, whose incremental read and kill methods are adapted by `dsh-tool-bash` into a generic task registration. A sandboxing executor stamps `BashSandboxInfo` on foreground results and settled process handles. See `src/types.ts` and [subsystems/bash.md](../../../docs/subsystems/bash.md).
|
||||
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
`stdin` and ordinary `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload and `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` values. `dshEnv` is a separate trusted overlay restricted by type to managed keys; the exported `DSH_ENV_PREFIX` is the single source for that namespace, its `DshEnvironmentKey` template type, executor scrubbing, registry validation, derived built-in names, and model guidance. Model bash uses the current snapshot collected by `ctx.bashEnv`. Implementations remove inherited managed keys, then merge `dshEnv` after ordinary `env`, so an omitted current fact cannot fall back to stale ambient state and an `env` entry cannot displace a managed value. The model-facing tool exposes none of these as parameters. All three remain optional on the resolved spec; absent means no input/overlay. See [the bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) and [the session environment Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md).
|
||||
|
||||
The exported `parseExitStatus` (with `ParsedExitStatus`) is the shared rendering contract half of the shell tools: the inverse of the `[exit code: N]` / `[killed by signal: X]` markers `dsh-tool-bash`'s `renderResult` and `dsh-tool-pwsh`'s `renderPwshResult` append. Both tools' `presentResult` use it to split the rendered text into the terminal card's output body and its exit-status pill; it lives with the Service Definition so the two tools never drift on the marker contract.
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
每会话沙箱模式覆盖词汇(`'sandbox/mode'` 事件、`effectiveSandboxMode(events)` fold 以及 `setSandboxMode(session, mode)` 写入路径)不位于此处。它是所有强制执行家族共享的策略状态,属于 [`@deepseek-ai/dsh-sandbox-policy`](../../sandbox/sandbox-policy/)。`run()` 返回 `BashRunResult`;`start()` 返回 `BashProcess`,其增量读取与终止方法由 `dsh-tool-bash` 适配为通用任务注册。沙箱执行器会在前台结果与已结算进程句柄上标记 `BashSandboxInfo`。详见 `src/types.ts` 与 [subsystems/bash.md](../../../docs/subsystems/bash.md)。
|
||||
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
`stdin` 与普通 `env` 由同进程插件(hooks 桥接、原生插件)设置,用于向 hook 命令提供其 JSON payload 和 `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` 值。`dshEnv` 是受类型限制、仅允许受管 key 的独立受信任 overlay;导出的 `DSH_ENV_PREFIX` 是该 namespace、其 `DshEnvironmentKey` 模板类型、执行器清理、注册表验证、派生内置名称与模型指引的统一来源。模型 bash 使用 `ctx.bashEnv` 收集的当前快照。实现会移除继承的受管 key,再在普通 `env` 之后合并 `dshEnv`,因此省略的当前事实不会回退到陈旧环境状态,`env` 条目也无法顶掉受管值。面向模型的工具不将这三者中的任何一个公开为参数。这三者在已解析 spec 上仍然可选;缺失表示没有输入/overlay。详见 [bash-stdin-env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md) 与 [会话环境 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-agent-session-identity-and-log-location.md)。
|
||||
|
||||
导出的 `parseExitStatus`(连同 `ParsedExitStatus`)是 shell 工具共享渲染约定的另一半:`dsh-tool-bash` 的 `renderResult` 与 `dsh-tool-pwsh` 的 `renderPwshResult` 追加的 `[exit code: N]`/`[killed by signal: X]` marker 的逆解析。两个工具的 `presentResult` 都用它把渲染文本拆成 terminal 卡的输出正文与其退出状态 pill;它放在 Service Definition 中,两个工具便永远不会在 marker 约定上漂移。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/pwsh-sandbox/README.md
|
||||
README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2
|
||||
README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec
|
||||
README.md: c47409c122ffce264f53fc41c786da015fdaab6b
|
||||
README.zh.md: 5b14185a71943d0a0a50f0bae353a3e22d0f1fb2
|
||||
|
||||
@@ -30,5 +30,5 @@ None directly; the denial surface belongs to the tool layer.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`.
|
||||
- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap.
|
||||
- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package).
|
||||
- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning.
|
||||
- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL.
|
||||
|
||||
@@ -30,5 +30,5 @@
|
||||
## 已知限制与后续工作
|
||||
|
||||
- **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。
|
||||
- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。
|
||||
- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。
|
||||
- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent(智能体)的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权,runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。
|
||||
- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源;PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Real-backend end-to-end: LocalSandboxProvider (win32 chain → the
|
||||
* windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with
|
||||
* REAL pwsh spawns confined through the runner — the debug-instance
|
||||
* verification of both modes: read-only denies every write (not even NUL),
|
||||
* workspace-write allows the workspace and temp while denying escape writes,
|
||||
* and denial/classification facts ride the settled result.
|
||||
* verification of both modes on ordinary user-owned paths: read-only denies
|
||||
* writes, workspace-write allows its promised roots while denying escape
|
||||
* writes, and the partial-enforcement/denial facts ride the settled result.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
@@ -29,21 +29,19 @@ function pwshAvailable(): boolean {
|
||||
describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => {
|
||||
let scratchRoot!: string
|
||||
let writableDir!: string
|
||||
let isolatedTemp!: string
|
||||
let outsideTempDir!: string
|
||||
let secretFile!: string
|
||||
let escapeFile!: string
|
||||
let executor!: SandboxPwshExecutor
|
||||
|
||||
beforeAll(async () => {
|
||||
// The escape probe must live OUTSIDE every legitimately granted tree: the
|
||||
// provider's workspace-write grants the workspace plus the REAL temp dir
|
||||
// (the 'backend-defined temp area', same as Landlock granting /tmp), so a
|
||||
// scratch dir under temp would inherit the grant and the probe would be a
|
||||
// false pass. A mkdtemp under the profile is removed by afterAll.
|
||||
// The workspace escape sits under the profile. A separate directory under
|
||||
// the ambient temp root proves that the root itself is not granted: the
|
||||
// runner creates its own private child and rewrites TMP/TEMP to it.
|
||||
scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-'))
|
||||
writableDir = join(scratchRoot, 'writable')
|
||||
mkdirSync(writableDir)
|
||||
isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-'))
|
||||
outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-'))
|
||||
secretFile = join(scratchRoot, 'secret.txt')
|
||||
writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary')
|
||||
escapeFile = join(scratchRoot, 'escaped.txt')
|
||||
@@ -58,15 +56,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(scratchRoot, { recursive: true, force: true })
|
||||
rmSync(isolatedTemp, { recursive: true, force: true })
|
||||
rmSync(outsideTempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => {
|
||||
it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
].join('')
|
||||
@@ -78,7 +76,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false)
|
||||
// A self-caught denial keeps the command exit 0: no denial fact.
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' })
|
||||
expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' })
|
||||
|
||||
// A raw failing write must classify as a denial of the ACL dialect.
|
||||
const denied = await executor.run(executor.resolve({
|
||||
@@ -86,26 +84,34 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement
|
||||
sandboxPolicy: policy,
|
||||
}))
|
||||
expect(denied.exitCode).not.toBe(0)
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
|
||||
expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' })
|
||||
}, 60_000)
|
||||
|
||||
it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => {
|
||||
it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => {
|
||||
const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir }
|
||||
const probe = [
|
||||
"$ErrorActionPreference='SilentlyContinue';",
|
||||
`try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`,
|
||||
"try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};",
|
||||
`try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`,
|
||||
`try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`,
|
||||
`try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`,
|
||||
"'TEMP-PATH: ' + $env:TEMP",
|
||||
].join('')
|
||||
const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy }))
|
||||
expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0)
|
||||
expect(result.stdout.text).toContain('TARGET-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('TEMP-WRITE: OK')
|
||||
expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED')
|
||||
expect(result.stdout.text).toContain('SECRET-READ: OK')
|
||||
expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true)
|
||||
expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false)
|
||||
expect(existsSync(escapeFile)).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
|
||||
const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim()
|
||||
expect(privateTemp).toBeDefined()
|
||||
expect(privateTemp?.startsWith(tmpdir())).toBe(true)
|
||||
expect(existsSync(privateTemp ?? '')).toBe(false)
|
||||
expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' })
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-bash/README.md
|
||||
README.md: 9e2cd0c2ed999a11dcbffcd99a1d0cb672367905
|
||||
README.zh.md: 8a1e7ba0af36ffd891b38491075ee75283fdbed5
|
||||
README.md: d5483e5e266a5362d9a8d324d71046591fe146bc
|
||||
README.zh.md: 527543ea7130cb89e7e9776cb71adec842527e85
|
||||
|
||||
@@ -42,7 +42,7 @@ The tool owns its `presentCall`/`presentResult` render intent. A foreground call
|
||||
|
||||
## The tool builds its request from named args only
|
||||
|
||||
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
|
||||
`BashExecRequest` carries optional `stdoutMaxBytes`, `stdin`, ordinary `env`, and managed `dshEnv`, used by trusted in-process plugins and this tool's environment registry. The model-facing tool exposes none of `stdoutMaxBytes`, `stdin`, or `env`: it builds requests from named command/workdir/timeout/signal/sandbox fields plus the registry-collected `dshEnv`. Extra model keys are ignored and cannot replace managed values. Shell syntax provides equivalent command-level behavior, while the local executor scrubs ambient credentials and stale `DSH_*` values. See the [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md).
|
||||
|
||||
## Permissions and escalation
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 工具仅使用具名参数构建请求
|
||||
|
||||
`BashExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md)。
|
||||
`BashExecRequest` 携带可选的 `stdoutMaxBytes`、`stdin`、普通 `env` 和托管 `dshEnv`,供可信进程内插件及此工具的环境注册表使用。模型侧工具不公开 `stdoutMaxBytes`、`stdin` 或 `env`:它使用具名的命令/工作目录/超时/信号/沙箱字段,加上从注册表收集的 `dshEnv` 来构建请求。额外模型键会被忽略,无法替换托管值。Shell 语法可以提供等价的命令级行为,而本地执行器会清除环境中的凭据和陈旧 `DSH_*` 值。参见 [stdin/env Agent Note](../../../.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md)。
|
||||
|
||||
## 权限与升权
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ export function renderResult(
|
||||
* Shape one background-process read into the `task_output` delta the model
|
||||
* sees: the incremental delta, plus the lossy-read notice (with full-stream
|
||||
* spill paths) when in-memory truncation dropped unread bytes. Empty-delta
|
||||
* rendering (`(no new output)`) is the generic control surface's job.
|
||||
* rendering (`(no new output)`) is the generic task controller's job.
|
||||
* @param read - one incremental read from the process handle.
|
||||
* @param sandbox - settled sandbox facts, when this was a confined process.
|
||||
* @param escalationModes - escalation targets advertised by this composition.
|
||||
|
||||
@@ -42,7 +42,7 @@ async function setup() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Full harness: the generic task runtime + its control surface, then the bash tool. */
|
||||
/** Full harness: the generic task runtime + its controller, then the bash tool. */
|
||||
async function setupWithTasks() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -524,7 +524,7 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
||||
// With no control surface, task preflight fails before the executor can spawn.
|
||||
// With no task controller, preflight fails before the executor can spawn.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -536,7 +536,7 @@ describe('background execution through the task runtime', () => {
|
||||
|
||||
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface serves this agent')
|
||||
expect(text(result)).toContain('no task controller serves this agent')
|
||||
// Declare-then-execute: the failed preflight means no process ever ran.
|
||||
expect((ctx.bash as CountingStartExecutor).starts).toBe(0)
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/bash/tool-pwsh/README.md
|
||||
README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8
|
||||
README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90
|
||||
README.md: 4126e718c569f17fb8be465351b2576970e93c63
|
||||
README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7
|
||||
|
||||
@@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations.
|
||||
- **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work.
|
||||
- **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation.
|
||||
- **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction.
|
||||
|
||||
@@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。
|
||||
- **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。
|
||||
- **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。
|
||||
- **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。
|
||||
|
||||
@@ -114,18 +114,18 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S
|
||||
+ 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. '
|
||||
+ background
|
||||
if (escalationModes.length === 0) return base
|
||||
// The CLM and named-pipe contracts below are Windows-restricted-token
|
||||
// The language-mode and named-pipe contracts below are Windows-restricted-token
|
||||
// behavior, but the gate is 'any confining executor is mounted'
|
||||
// (escalationModes non-empty). The conflation is safe today because every
|
||||
// shipped composition pairing tool-pwsh with a confining executor is
|
||||
// win32-only; a future POSIX pwsh-sandbox composition must gate both
|
||||
// sentences on the platform instead (tracked in the pwsh-tool-and-executor
|
||||
// Agent Note).
|
||||
return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and '
|
||||
+ 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
|
||||
return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while '
|
||||
+ 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); '
|
||||
+ '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail '
|
||||
+ 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. '
|
||||
+ 'In the same modes, programs cannot open named pipes, so a command that captures another '
|
||||
+ 'In both confined modes, programs cannot open named pipes, so a command that captures another '
|
||||
+ 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default '
|
||||
+ '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns '
|
||||
+ 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: '
|
||||
|
||||
@@ -139,7 +139,7 @@ async function setup(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string
|
||||
return { ctx, bash }
|
||||
}
|
||||
|
||||
/** Full harness: the generic task runtime + its control surface, then the pwsh tool. */
|
||||
/** Full harness: the generic task runtime + its controller, then the pwsh tool. */
|
||||
async function setupWithTasks(toolConfig: Partial<ToolPwsh.Config> = {}, dshHome?: string) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
@@ -559,7 +559,8 @@ describe('sandbox escalation through ctx.approval', () => {
|
||||
expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access'])
|
||||
expect(schema.description).toContain('approval prompt')
|
||||
expect(schema.description).toContain('ConstrainedLanguage')
|
||||
expect(schema.description).toContain('named pipes')
|
||||
expect(schema.description).toContain('workspace-write stays in FullLanguage')
|
||||
expect(schema.description).toContain('In both confined modes, programs cannot open named pipes')
|
||||
expect(schema.description).toContain('fails with EPERM')
|
||||
|
||||
for (const args of [
|
||||
@@ -765,7 +766,7 @@ describe('background execution through the task runtime', () => {
|
||||
})
|
||||
|
||||
it('never spawns the process when tasks.start preflight throws (no orphan, by construction)', async () => {
|
||||
// With no control surface, task preflight fails before the executor can spawn.
|
||||
// With no task controller, preflight fails before the executor can spawn.
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
@@ -777,7 +778,7 @@ describe('background execution through the task runtime', () => {
|
||||
|
||||
const result = await call(ctx, 'pwsh', { command: 'Start-Sleep -Seconds 60', description: 'test command', run_in_background: true })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(text(result)).toContain('no control surface serves this agent')
|
||||
expect(text(result)).toContain('no task controller serves this agent')
|
||||
// Declare-then-execute: the failed preflight means no process ever ran.
|
||||
expect(bash.startCalls).toBe(0)
|
||||
})
|
||||
|
||||
@@ -190,7 +190,7 @@ function ensureSymlink(link: string, target: string): void {
|
||||
// Concurrent launches heal the same fallback; losing the race to a
|
||||
// process writing the identical link is success, anything else is not.
|
||||
// The window between the lstat miss above and this write cannot be
|
||||
// staged deterministically from the public surface.
|
||||
// staged deterministically from the public API.
|
||||
/* v8 ignore next 4 */
|
||||
if ((error as NodeJS.ErrnoException).code !== 'EEXIST'
|
||||
|| !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) {
|
||||
|
||||
@@ -311,12 +311,16 @@
|
||||
toolName: subagent
|
||||
backgroundMode: continuable
|
||||
|
||||
# Fork stays one-shot: a continuable child's `report` tool and prompt
|
||||
# section precede the inherited history a fork exists to reuse; one-shot
|
||||
# fork children install neither, keeping the parent's request prefix.
|
||||
# See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md.
|
||||
- id: tool-subagent-fork
|
||||
name: '@deepseek-ai/dsh-tool-subagent'
|
||||
config:
|
||||
provider: fork
|
||||
toolName: subagent_fork
|
||||
backgroundMode: continuable
|
||||
backgroundMode: one-shot
|
||||
|
||||
# Optional direct-child return channel; absent from roots and one-shot agents.
|
||||
- id: tool-subagent-report
|
||||
@@ -383,7 +387,7 @@
|
||||
thresholds: [3, 5, 8]
|
||||
argumentsPreviewChars: 500
|
||||
|
||||
# Every mode enables the stable web_search model surface. DeepSeek search
|
||||
# Every mode enables the stable model-facing web_search tool. DeepSeek search
|
||||
# resolves the same DEEPSEEK_API_KEY credential the Models page manages for
|
||||
# chat, at each search; its Messages endpoint is separate from the
|
||||
# chat-completions endpoint, so it takes its own base-URL override. Fetch stays
|
||||
|
||||
@@ -106,6 +106,10 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise<void> {
|
||||
if (agents === undefined || defaultModel === undefined || sessions === undefined) return
|
||||
|
||||
const selection = defaultModel.currentSelection()
|
||||
// This bundle composes no preset roster, so the model-facing rows sit in the
|
||||
// host plane and the agent reads them from the global layer. A deployment
|
||||
// that DOES configure one has to join it here first
|
||||
// (@deepseek-ai/dsh-agent-presets README, "Composing a child agent").
|
||||
const { agent } = await agents.create({
|
||||
sessionId: SessionId(`session-${randomUUID()}`),
|
||||
meta: { cwd: process.cwd() },
|
||||
|
||||
@@ -309,8 +309,12 @@
|
||||
- id: plan-mode
|
||||
disabled: true
|
||||
|
||||
- id: token-meter
|
||||
disabled: true
|
||||
# The token METER stays on the host plane; only the compaction backend that
|
||||
# reads it moves. It owns the context-meter projection units, and that table is
|
||||
# process-wide, so preset ownership would make the meter a function of which
|
||||
# presets happen to be mounted rather than a per-session fact. Same criterion as
|
||||
# `tasks` and `goals`; the reasoning has one home in
|
||||
# `.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md`.
|
||||
|
||||
- id: compact-basic
|
||||
disabled: true
|
||||
|
||||
@@ -13,7 +13,7 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
|
||||
3. **Component props are the four shares, all derived**: `PropsRuntime<K>` (SlotMap: owner params + `useSession`/`sessionId` on session scope + global `useSessions`/`useWorkspaces`) & `PropsRenderSlots<S>` (children keys) & `PropsStore<H>` (store factory) & the inject face. Never hand-write a member a share already derives; never re-type a share locally.
|
||||
4. **Hooks are framework-made only**: `useSession`, `useSessions`, `useWorkspaces`, `useStore`, `renderSlot` are the five standing seats, plus the `use<Name>` hooks the renderer binds from provide contributions and inject `hooks` compartments. Business code never creates a hook or selector as a prop value — pass plain data and callbacks. (Component-internal behavioral hooks that subscribe to nothing external are fine.)
|
||||
5. **Live data has exactly three channels**: parent knows it → owner props at the renderSlot site; only the component knows it → local state; shared across entries or survives remounts → a store declared at register. Derived data is a pure function over framework-hook data (`useMemo`), never its own subscription.
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation API. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
|
||||
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact uses the reserved `hooks` compartment (bare observables the renderer binds to `use<Name>`; components never see the sources). The plugin may use only the dependencies named by its `inject` declaration; there is no wider ctx to reach for.
|
||||
|
||||
## Reactive read and contract-currency discipline
|
||||
@@ -32,7 +32,7 @@ How live data reaches render code, and what UI domains may share:
|
||||
The `/client` entrypoint of a UI plugin package is its public browser API, not a convenience barrel. Three rules apply package-wide (do not restate them as per-file comments):
|
||||
|
||||
1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType<typeof createXXXStore>`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public surface to make a test compile.
|
||||
2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile.
|
||||
3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself.
|
||||
|
||||
## ctx discipline (components never see ctx)
|
||||
|
||||
@@ -71,7 +71,7 @@ const MARKDOWN_FIXTURE = [
|
||||
'- first item',
|
||||
' - nested item',
|
||||
'',
|
||||
'| Surface | State |',
|
||||
'| Area | State |',
|
||||
'| --- | --- |',
|
||||
'| history | rendered |',
|
||||
'| streaming | stable |',
|
||||
@@ -2836,6 +2836,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
|
||||
})
|
||||
return Promise.resolve({ accepted: true })
|
||||
},
|
||||
// Satisfies the ApiProxy contract type only: the browser export button
|
||||
// fetches GET /api/session.export directly (window.fetch), so this stub is
|
||||
// never reached through the fixture's dispatch.
|
||||
downloads: {
|
||||
sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })),
|
||||
},
|
||||
}
|
||||
|
||||
const rpc: ClientConnectionRpc = {
|
||||
|
||||
@@ -53,7 +53,7 @@ export interface HostDescriptionSource {
|
||||
export const inject: string[] = []
|
||||
|
||||
/**
|
||||
* The ctx.connection service surface: the api client plus a one-shot
|
||||
* The ctx.connection service API: the API client plus a one-shot
|
||||
* controller starter (the runtime plugin supplies sinks when its object layer
|
||||
* is ready — connection stays consumer-agnostic).
|
||||
*/
|
||||
|
||||
@@ -16,7 +16,7 @@ export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: ns-by-locale dictionary registry with a stable
|
||||
* bind(ns) surface — it emits no cordis events and owns no cross-plugin
|
||||
* bind(ns) API — it emits no cordis events and owns no cross-plugin
|
||||
* mutable relation; fallback-chain resolution and locale-store behavior are
|
||||
* asserted directly by this package's behavior specs.
|
||||
*/
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/modules/README.md
|
||||
README.md: a1d578850c2518a85dc32f048768b78caf5ffec4
|
||||
README.md: efaff699839b977cc45f89f3c164402241b90dc2
|
||||
README.zh.md: 772a4870f7ef6730d9d3d4db434ed771d97984f0
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Client module system: the browser peer of Node's internal ESM loader, built as a lazy CJS table. The web shell mounts the vendored cordis Loader for entry governance (fiber lifecycle, inject waiting, update/refresh) and injects this package's `ClientModuleLoader` through its `internal` contract — the vendored side's only consumption point is `EntryTree.import`, so replacing `internal` replaces exactly "how plugin code arrives" and nothing else.
|
||||
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → export surface, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id name the same surface (a plugin bundle IS its package's client half).
|
||||
Lazy CJS model (web2): executing a plugin bundle only REGISTERS its factory (`window.__ModuleLoader__.load({id, factory})`); every module body side effect — CSS injection included — lives in the factory closure and runs at materialization (`factory(require)` → exports, memoized in `loadCache`), not at script execution. A factory that requires another registered-but-unmaterialized module materializes it recursively, so load order needs no external sequencing; require cycles throw (factory-form CJS cannot deliver partial exports). `<id>/client` and the bare id resolve to the same exports (a plugin bundle IS its package's client half).
|
||||
|
||||
Resolution branch order (`import(specifier)`): platform seed word → shell instance; memoized record → surface; shell-own static registry (`registerStatic`, app-shell) → module; registered factory → materialize; graph row (`window.__DSH_BOOT__`) → load its external classic script + materialize; anything else throws — the runtime mirror of the build-time bundle purity gate. The synchronous `require` handed to factories walks the same order minus the asynchronous load branch and records observed edges into the module record. `prefetch` is the stage-one arrival hook (script load and factory registration only; concurrent calls share one in-flight task); `invalidate` drops the factory and materialized record so the next prefetch/import reloads the script (the HMR hook).
|
||||
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
* factory (`window.__ModuleLoader__.load({id, factory})`); every module body
|
||||
* side effect — including CSS injection — lives inside the factory closure
|
||||
* and runs at materialization, not at script execution. Materialization
|
||||
* (factory(require) → export surface) happens on first import/require and is
|
||||
* (factory(require) → exports) happens on first import/require and is
|
||||
* memoized in {@link ClientModuleLoader.loadCache}; a factory that requires
|
||||
* another registered-but-unmaterialized module materializes it recursively,
|
||||
* so load order needs no external sequencing.
|
||||
*
|
||||
* Resolution branch order (import): seed word → shell instance; memoized
|
||||
* record → surface; static registry (shell-own modules, e.g. app-shell) →
|
||||
* record → exports; static registry (shell-own modules, e.g. app-shell) →
|
||||
* module; registered factory → materialize; graph row → load + materialize;
|
||||
* anything else → throw (loud — the runtime mirror of the
|
||||
* build-time bundle purity gate). The synchronous `require` handed to
|
||||
@@ -149,13 +149,13 @@ export interface ClientPluginHandoff {
|
||||
id: string
|
||||
/**
|
||||
* Closure factory holding the whole bundle body: receives the synchronous
|
||||
* require bound to the module table and returns the bundle's export
|
||||
* surface. Runs once, at materialization.
|
||||
* require bound to the module table and returns the bundle's exports. Runs
|
||||
* once, at materialization.
|
||||
*/
|
||||
factory: (require: (spec: string) => unknown) => Record<string, unknown>
|
||||
}
|
||||
|
||||
/** Window surface of the web boot protocol: the host-injected graph, the registration sink, and the kernel handoff slot. */
|
||||
/** Window API of the web boot protocol: the host-injected graph, registration sink, and kernel handoff slot. */
|
||||
export interface DshWindow {
|
||||
/** Host-composed entry graph, injected before the shell bundle runs; wire-boundary raw until {@link parseBootManifest}. */
|
||||
__DSH_BOOT__?: unknown
|
||||
@@ -174,8 +174,8 @@ export interface DshWindow {
|
||||
export interface ClientModuleRecord {
|
||||
/** Module id (entry name / package name). */
|
||||
id: string
|
||||
/** The materialized export surface (factory `module.exports`, or the shell module for static registrations). */
|
||||
surface: unknown
|
||||
/** Materialized exports (`module.exports` from a factory, or a statically registered shell module). */
|
||||
exports: unknown
|
||||
/** Owned `<style data-plugin>` tag ids (`data-plugin-css` values) injected during materialization. */
|
||||
styles: string[]
|
||||
/** Observed `require()` edges (module-graph boundary; only table words can appear today). */
|
||||
@@ -190,7 +190,7 @@ export interface ClientModuleRecord {
|
||||
export interface ClientModuleLoader {
|
||||
/** Discriminant against Node's internal loader shapes ('v1'/'v2'). */
|
||||
version: 'client'
|
||||
/** Materialized-module registry: id → record. The governance-side read face for entry export surfaces. */
|
||||
/** Materialized-module registry: id → record. The governance-side read API for entry exports. */
|
||||
loadCache: Map<string, ClientModuleRecord>
|
||||
/**
|
||||
* Internal contract consumed by the vendored Loader's `tree.import`. Resolves
|
||||
@@ -199,7 +199,7 @@ export interface ClientModuleLoader {
|
||||
* @param specifier - module specifier (entry name or table word).
|
||||
* @param parentURL - importer URL (unused — the client module graph is flat).
|
||||
* @param attrs - Import attributes (unused; interface parity with Node's loader contract).
|
||||
* @returns the module's export surface.
|
||||
* @returns the module's exports.
|
||||
*/
|
||||
import(specifier: string, parentURL: string, attrs: Record<string, unknown>): Promise<unknown>
|
||||
/**
|
||||
|
||||
@@ -28,7 +28,7 @@ const defaultLoadBundle = (url: string): Promise<void> => new Promise((resolve,
|
||||
/**
|
||||
* A plugin bundle IS its package's client half: `<id>/client` (the exports
|
||||
* subpath external bundles emit) and the bare graph id name the same
|
||||
* surface, so table lookups normalize the suffix away.
|
||||
* exports, so table lookups normalize the suffix away.
|
||||
*/
|
||||
const stripClientSuffix = (spec: string): string =>
|
||||
spec.endsWith('/client') ? spec.slice(0, -'/client'.length) : spec
|
||||
@@ -123,8 +123,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
this.materializing.add(id)
|
||||
try {
|
||||
const edges = new Set<string>()
|
||||
const surface = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, surface, styles: claimStyles(id), edges }
|
||||
const exports = registered(this.makeRequire(edges))
|
||||
const record: ClientModuleRecord = { id, exports, styles: claimStyles(id), edges }
|
||||
this.loadCache.set(id, record)
|
||||
return record
|
||||
} finally {
|
||||
@@ -146,8 +146,8 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
if (this.statics.has(spec)) return this.statics.get(spec)
|
||||
const id = stripClientSuffix(spec)
|
||||
const record = this.loadCache.get(id)
|
||||
if (record !== undefined) return record.surface
|
||||
if (this.factories.has(id)) return this.materialize(id).surface
|
||||
if (record !== undefined) return record.exports
|
||||
if (this.factories.has(id)) return this.materialize(id).exports
|
||||
throw new Error(
|
||||
`client-modules: require("${spec}") missed the module table — not a platform seed word, not a shell-own module, `
|
||||
+ 'and no registered factory (a build-time externals drift, or a forbidden cross-plugin value import)',
|
||||
@@ -158,11 +158,11 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
async import(specifier: string): Promise<unknown> {
|
||||
if (this.seed.has(specifier)) return this.seed.get(specifier)
|
||||
const existing = this.loadCache.get(specifier)
|
||||
if (existing !== undefined) return existing.surface
|
||||
if (existing !== undefined) return existing.exports
|
||||
if (this.statics.has(specifier)) {
|
||||
const surface = this.statics.get(specifier)
|
||||
this.loadCache.set(specifier, { id: specifier, surface, styles: [], edges: new Set() })
|
||||
return surface
|
||||
const exports = this.statics.get(specifier)
|
||||
this.loadCache.set(specifier, { id: specifier, exports, styles: [], edges: new Set() })
|
||||
return exports
|
||||
}
|
||||
if (!this.factories.has(specifier)) {
|
||||
const row = this.graphRows.get(specifier)
|
||||
@@ -174,7 +174,7 @@ export class ClientModuleSystem implements ClientModuleLoader {
|
||||
}
|
||||
await this.arrive(row)
|
||||
}
|
||||
return this.materialize(specifier).surface
|
||||
return this.materialize(specifier).exports
|
||||
}
|
||||
|
||||
registerStatic(id: string, module: unknown): void {
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('lazy CJS arrival', () => {
|
||||
expect(b.loader.loadCache.size).toBe(0)
|
||||
})
|
||||
|
||||
it('import materializes once and memoizes the export surface', async () => {
|
||||
it('import materializes once and memoizes the exports', async () => {
|
||||
const ran: string[] = []
|
||||
const b = bench([row('a')], { a: () => { ran.push('a'); return { marker: 'a' } } })
|
||||
const first = await b.loader.import('a', '', {})
|
||||
@@ -83,8 +83,8 @@ describe('lazy CJS arrival', () => {
|
||||
|
||||
it('import without prefetch loads, registers, and materializes in one call', async () => {
|
||||
const b = bench([row('a')], { a: () => ({ marker: 'direct' }) })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('direct')
|
||||
const exports = await b.loader.import('a', '', {})
|
||||
expect((exports as { marker: string }).marker).toBe('direct')
|
||||
expect(b.fetched).toHaveLength(1)
|
||||
})
|
||||
|
||||
@@ -123,8 +123,8 @@ describe('require resolution', () => {
|
||||
})
|
||||
await b.loader.prefetch('a')
|
||||
await b.loader.prefetch('b')
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { got: string }).got).toBe('from-b')
|
||||
const exports = await b.loader.import('a', '', {})
|
||||
expect((exports as { got: string }).got).toBe('from-b')
|
||||
expect(order).toEqual(['a', 'b'])
|
||||
expect(b.loader.loadCache.get('a')?.edges.has('b/client')).toBe(true)
|
||||
expect(b.loader.loadCache.has('b')).toBe(true)
|
||||
@@ -135,8 +135,8 @@ describe('require resolution', () => {
|
||||
const b = bench([row('a')], {
|
||||
a: req => ({ dep: req('react') }),
|
||||
}, { seed: { react } })
|
||||
const surface = await b.loader.import('a', '', {})
|
||||
expect((surface as { dep: unknown }).dep).toBe(react)
|
||||
const exports = await b.loader.import('a', '', {})
|
||||
expect((exports as { dep: unknown }).dep).toBe(react)
|
||||
expect(await b.loader.import('react', '', {})).toBe(react)
|
||||
expect(b.loader.loadCache.has('react')).toBe(false)
|
||||
})
|
||||
@@ -284,8 +284,8 @@ describe('default transport seam', () => {
|
||||
})
|
||||
})
|
||||
const loader: ClientModuleLoader = new ClientModuleSystem({ modules: [row('dee')], staticModules: {} })
|
||||
const surface = await loader.import('dee', '', {})
|
||||
expect((surface as { marker: string }).marker).toBe('via-script')
|
||||
const exports = await loader.import('dee', '', {})
|
||||
expect((exports as { marker: string }).marker).toBe('via-script')
|
||||
expect(append).toHaveBeenCalledOnce()
|
||||
expect([...document.querySelectorAll('script')]).toEqual([])
|
||||
})
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Store contract types are ui-slots authority; re-exported beside the engine
|
||||
// so store consumers get one import surface.
|
||||
// so store consumers get one import path.
|
||||
export type {
|
||||
ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
|
||||
} from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -165,7 +165,7 @@ function deepFreeze(value: unknown): void {
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
/** The underlying engine store (framework/test surface; components never see it). */
|
||||
/** The underlying engine store (framework/test API; components never see it). */
|
||||
readonly store: SnapshotStore<T>
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ export function apply(ctx: Context): void {
|
||||
workspaces.handleHostEnvelope(envelope)
|
||||
// Typed-event bridge: the session layer ignores registry frames (no
|
||||
// session routing); consumers (command directory caches, the settings
|
||||
// and model surfaces) subscribe on ctx.
|
||||
// and model services) subscribe on ctx.
|
||||
const frame = envelope.payload
|
||||
if (frame.type === 'host/commands-changed') ctx.emit('commands/changed')
|
||||
else if (frame.type === 'host/session-preset-changed') {
|
||||
|
||||
@@ -430,7 +430,7 @@ export class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- List surface ----
|
||||
// ---- List API ----
|
||||
|
||||
/** Full refresh via session.list (single-flight: an in-flight call is reused). */
|
||||
refreshList(): Promise<void> {
|
||||
@@ -629,7 +629,7 @@ export class SessionManager {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (for useSessionList) ----
|
||||
// ---- Subscription API (for useSessionList) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry for useSessionList.
|
||||
|
||||
@@ -60,7 +60,7 @@ export interface SessionOptions {
|
||||
* remaining public members are manager/runtime entry points.
|
||||
*/
|
||||
export class Session implements SessionFace {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
// ---- Window and derived state (all private; the snapshot is the only read API) ----
|
||||
private events: SessionEvent[] = []
|
||||
/** Wire views aligned with `events` by index (envelope-level annotations; undefined = no view).
|
||||
* Kept parallel rather than merged so `events` stays the raw log slice (model-visible ⟺ logged). */
|
||||
@@ -428,7 +428,7 @@ export class Session implements SessionFace {
|
||||
await this.open()
|
||||
}
|
||||
|
||||
// ---- Subscription surface (useSyncExternalStore direct wiring) ----
|
||||
// ---- Subscription API (useSyncExternalStore direct wiring) ----
|
||||
|
||||
/**
|
||||
* uSES subscription entry.
|
||||
|
||||
@@ -25,7 +25,7 @@ const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||
|
||||
/**
|
||||
* Wire/type layers a client bundle may inline: browser-safe contract surfaces
|
||||
* Wire/type layers a client bundle may inline: browser-safe contracts
|
||||
* with no runtime identity to share (no Symbol/instanceof/singleton state).
|
||||
* Everything else under @deepseek-ai/* is either a module-table entry
|
||||
* (external) or a leak the purity gate rejects.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
|
||||
README.md: 008066114e9c49e5c74299979e24c27a4c9621c9
|
||||
README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55
|
||||
README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e
|
||||
README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00
|
||||
|
||||
@@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`,
|
||||
|
||||
The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing.
|
||||
|
||||
A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows.
|
||||
|
||||
A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode.
|
||||
|
||||
Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default.
|
||||
@@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re
|
||||
|
||||
Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file.
|
||||
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start.
|
||||
|
||||
Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget).
|
||||
|
||||
|
||||
@@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其
|
||||
|
||||
浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。
|
||||
|
||||
preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。
|
||||
|
||||
随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
|
||||
复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。
|
||||
@@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其
|
||||
|
||||
删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。
|
||||
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记(discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。
|
||||
|
||||
设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。
|
||||
|
||||
|
||||
@@ -161,15 +161,28 @@
|
||||
color: var(--dsw-alias-bg-layer-3);
|
||||
}
|
||||
|
||||
/* Bounded to four lines. A preset publishes its own description, so one long
|
||||
one would otherwise stretch every card in its grid row (`.cards` sizes rows
|
||||
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
|
||||
tech, and the card offers it on hover when it is actually cut off. The
|
||||
description does not grow to fill the card — `-webkit-line-clamp` on a
|
||||
flex-stretched box leaves the clamp height and the box height disagreeing,
|
||||
so `.cardId` takes the free space with an auto margin instead. */
|
||||
.cardDesc {
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
flex: 1;
|
||||
min-height: 42px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
overflow: hidden;
|
||||
/* A user-authored description may carry an unbreakable path or URL. */
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.cardId {
|
||||
margin-top: auto;
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11px;
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
* mounted once at session creation and nothing re-reads the file.
|
||||
*/
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal,
|
||||
Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one card's description, clamped by CSS and offered in full on hover.
|
||||
* The tooltip is attached only while the text is actually cut off, so a short
|
||||
* description does not answer a hover with a bubble repeating the card.
|
||||
* @param props.text - the description as rendered, already localized.
|
||||
* @returns the description element, tooltip-anchored while it overflows.
|
||||
*/
|
||||
function CardDescription({ text }: { text: string }): ReactNode {
|
||||
const ref = useRef<HTMLSpanElement | null>(null)
|
||||
const [truncated, setTruncated] = useState(false)
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current
|
||||
/* v8 ignore next -- the ref is attached before layout effects run. */
|
||||
if (el === null) return
|
||||
const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) }
|
||||
measure()
|
||||
// Card width follows the settings pane, which resizes with the window.
|
||||
if (typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(measure)
|
||||
observer.observe(el)
|
||||
return () => { observer.disconnect() }
|
||||
}, [text])
|
||||
return (
|
||||
// Capped near the card's own width: the default half-viewport bubble would
|
||||
// spill a description out of the settings dialog and across the app behind it.
|
||||
<Tooltip label={text} side="bottom" delayMs={400} disabled={!truncated} maxWidth={360}>
|
||||
{/* The empty title stops the card body's native tooltip from climbing to
|
||||
this span: a cut-off description answers with one bubble, not two. */}
|
||||
<span ref={ref} className={css.cardDesc} title="">{text}</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the Agent presets section content column.
|
||||
* @param props - composed slot props.
|
||||
@@ -247,7 +280,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
<CardDescription text={text.description ?? t('noDescription')} />
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
|
||||
@@ -57,8 +57,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
brokenBadge: 'Broken',
|
||||
brokenNoCopy: 'Broken presets cannot be duplicated',
|
||||
brokenBadge: 'Failed to load',
|
||||
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
@@ -117,8 +117,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
brokenBadge: '已损坏',
|
||||
brokenNoCopy: '预设已损坏,无法复制',
|
||||
brokenBadge: '加载失败',
|
||||
brokenNoCopy: '预设加载失败,不能复制',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* action follows the host's desktop capability.
|
||||
*/
|
||||
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
@@ -452,3 +452,68 @@ describe('deleting a preset', () => {
|
||||
expect(actions.remove).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('a long card description', () => {
|
||||
/** jsdom has no ResizeObserver; the description watches its own box through one. */
|
||||
class ResizeObserverStub {
|
||||
observe(): void {}
|
||||
unobserve(): void {}
|
||||
disconnect(): void {}
|
||||
}
|
||||
|
||||
const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8)
|
||||
|
||||
/** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */
|
||||
function clamp(overflowing: boolean): void {
|
||||
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80)
|
||||
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80)
|
||||
}
|
||||
|
||||
beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) })
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('offers the whole description on hover once the card cuts it off', () => {
|
||||
clamp(true)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] })
|
||||
|
||||
fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
expect(screen.getByRole('tooltip').textContent).toBe(LONG)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('stays quiet when the description already fits', () => {
|
||||
clamp(false)
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] })
|
||||
|
||||
fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。'))
|
||||
act(() => { vi.advanceTimersByTime(400) })
|
||||
|
||||
// A bubble repeating what is already fully on the card is noise.
|
||||
expect(screen.queryByRole('tooltip')).toBeNull()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('renders where the runtime has no ResizeObserver', () => {
|
||||
vi.unstubAllGlobals()
|
||||
clamp(true)
|
||||
|
||||
expect(() => {
|
||||
renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] })
|
||||
}).not.toThrow()
|
||||
// The first measurement does not depend on the observer.
|
||||
expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 605bba15d704c0c6e9f28abb3cddeb68bdd7e0d8
|
||||
README.md: ed8f888d35693ecaa2667ea432b462f4bb3369cf
|
||||
README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a
|
||||
|
||||
@@ -40,7 +40,7 @@ The composer bar declares session-scoped single seats for `'conversation.input.p
|
||||
|
||||
The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation.
|
||||
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
`src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` exports contain only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations.
|
||||
|
||||
A finished turn materializes one ordered `turn-tail` Conversation Node. Its engine-owned `TurnLocation` supplies the closing Assistant and Turn data; the renderer places the `conversation.chat.turnTail` chain before that node's IconActions and dispatches `TurnTailOwnerProps` containing the Turn, closing seq, and `openFile`. This package owns only the hole; `@deepseek-ai/dsh-client-ui-deliverables` accumulates mutation-tool `locations` into Turn data and owns the produced-files row, chip cap, and copy, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert.
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export class ConversationService extends Service implements IConversation {
|
||||
|
||||
/**
|
||||
* Send a prompt into the scoped session. Business failures also land in the
|
||||
* session snapshot's promptError (object-layer surface); the rejection here
|
||||
* session snapshot's promptError (object-layer state); the rejection here
|
||||
* exists for caller choreography (the composer restores the draft on it).
|
||||
* @param text - prompt text, sent verbatim as one text block.
|
||||
*/
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
// @vitest-environment jsdom
|
||||
// apply inject factories exercised end to end against the terminal thin
|
||||
// shape: the strict session surface (views triple, draft mirror), the
|
||||
// API: the strict session API (views triple, draft mirror), the
|
||||
// provide-channel input face (machine-sink submit choreography incl.
|
||||
// optimistic clear + failure restore), the resident surface (selectWorkspace
|
||||
// optimistic clear + failure restore), the resident API (selectWorkspace
|
||||
// draft carrying), the composer-bar stop face, openDetails = select action +
|
||||
// layout orchestration, and the closeDetails details surface. Complements
|
||||
// layout orchestration, and the closeDetails details API. Complements
|
||||
// chat-apply.spec.tsx (registration) and selection-survival.spec.tsx (store
|
||||
// axis). History opening is NOT an inject concern — the runtime sessions
|
||||
// service opens on watch (sessions-service.spec.ts owns that behavior).
|
||||
//
|
||||
// The inject surfaces are read off the ledger entries deliberately (typed at
|
||||
// The inject APIs are read off the ledger entries deliberately (typed at
|
||||
// this spec's own contract): these cases pin factory choreography the UI
|
||||
// guards would mask. Rendering-path acceptance lives in
|
||||
// chat-toolview-slot.spec.tsx.
|
||||
@@ -75,30 +75,30 @@ async function bench() {
|
||||
const entryOf = (key: 'conversation' | 'conversation.session' | 'conversation.session.header' | 'conversation.composer.bar' | 'conversation.view' | 'details') =>
|
||||
runtime.slots.entries(key)[0]!
|
||||
/** Resolve store instance + call the inject the way the outlet would. */
|
||||
const conversationSurface = (id: SessionId) => {
|
||||
const conversationApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session')
|
||||
const instance = runtime.storeOf('conversation.session', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const conversationHeaderSurface = (id: SessionId) => {
|
||||
const conversationHeaderApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.session.header')
|
||||
const instance = runtime.storeOf('conversation.session.header', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ConversationSessionHeaderInjected)(
|
||||
id, instance.actions)
|
||||
return { instance, injected }
|
||||
}
|
||||
const residentSurface = (id: SessionId | undefined) => {
|
||||
const residentApi = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ConversationInjected)(id)
|
||||
}
|
||||
const composerSurface = (id: SessionId | undefined) => {
|
||||
const composerApi = (id: SessionId | undefined) => {
|
||||
const entry = entryOf('conversation.composer.bar')
|
||||
return (entry.inject as unknown as (sessionId: SessionId | undefined) => ComposerBarInjected)(id)
|
||||
}
|
||||
/** Same resolution for the chat entry riding the view ring. */
|
||||
const chatViewSurface = (id: SessionId) => {
|
||||
const chatViewApi = (id: SessionId) => {
|
||||
const entry = entryOf('conversation.view')
|
||||
const instance = runtime.storeOf('conversation.view', id) as ChatInstance
|
||||
const injected = (entry.inject as unknown as (sessionId: SessionId, actions: ChatActions) => ChatViewInjected)(
|
||||
@@ -106,7 +106,7 @@ async function bench() {
|
||||
return { instance, injected }
|
||||
}
|
||||
/** Materialize the input provide contribution the way the runtime does. */
|
||||
const inputSurface = (id: SessionId) => {
|
||||
const inputApi = (id: SessionId) => {
|
||||
const info = runtime.sessions.provideInfo(id)!
|
||||
const state = info.hooks['input'] as {
|
||||
getSnapshot: () => { draft: string }
|
||||
@@ -120,21 +120,21 @@ async function bench() {
|
||||
}
|
||||
return {
|
||||
runtime, feature, slots: runtime.slots, entryOf,
|
||||
conversationSurface, conversationHeaderSurface, residentSurface, composerSurface, chatViewSurface, inputSurface,
|
||||
conversationApi, conversationHeaderApi, residentApi, composerApi, chatViewApi, inputApi,
|
||||
sessionFake, layoutFake,
|
||||
}
|
||||
}
|
||||
|
||||
describe('conversation slot inject surface', () => {
|
||||
it('assembles the thin surface side-effect-free', async () => {
|
||||
describe('conversation slot inject API', () => {
|
||||
it('assembles the thin API side-effect-free', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
// Assembly has no session side effects: opening the event window belongs
|
||||
// to the runtime watch path, not the inject factory.
|
||||
expect(b.sessionFake.open).not.toHaveBeenCalled()
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
const chatView = b.chatViewApi(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
chatView.injected.forkAt(17)
|
||||
@@ -149,8 +149,8 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('the provide-channel input face submits through the machine sink: trim, optimistic clear, failure restore without clobber', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
const { state, actions } = b.inputApi(ROOT)
|
||||
// Whitespace-only: the machine treats it as empty — no prompt, draft kept.
|
||||
actions.setDraft(' ')
|
||||
actions.submit()
|
||||
@@ -176,16 +176,16 @@ describe('conversation slot inject surface', () => {
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(state.getSnapshot().draft).toBe('typed during flight')
|
||||
// The provide contribution is idempotent per session: one shell identity.
|
||||
expect(b.inputSurface(ROOT).state).toBe(state)
|
||||
expect(b.inputApi(ROOT).state).toBe(state)
|
||||
// The draft mirror rides the conversation inject face.
|
||||
const mirrored: string[] = []
|
||||
const unbind = injected.bindDraftMirror(text => mirrored.push(text))
|
||||
actions.setDraft('mirrored text')
|
||||
expect(mirrored).toEqual(['mirrored text'])
|
||||
unbind()
|
||||
// Stop failure is swallowed (promptError owns the surface).
|
||||
// Stop failure is swallowed (promptError owns the display).
|
||||
b.sessionFake.cancel.mockResolvedValueOnce({ ok: false, error: { code: 'internal', message: 'x', details: {} } })
|
||||
b.composerSurface(ROOT).stop!()
|
||||
b.composerApi(ROOT).stop!()
|
||||
await new Promise(r => setTimeout(r, 0))
|
||||
expect(b.sessionFake.cancel).toHaveBeenCalledTimes(1)
|
||||
await b.runtime.dispose()
|
||||
@@ -216,20 +216,20 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('openDetails (chat view face) writes the selection through the store actions and opens the panel', async () => {
|
||||
const b = await bench()
|
||||
const { instance, injected } = b.chatViewSurface(ROOT)
|
||||
const { instance, injected } = b.chatViewApi(ROOT)
|
||||
injected.openDetails({ turnSeq: 2, callId: 'c1' })
|
||||
expect(instance.store.getSnapshot().selection).toEqual({ turnSeq: 2, callId: 'c1' })
|
||||
expect(b.layoutFake.openDetails).toHaveBeenCalledTimes(1)
|
||||
// The chat view shares the conversation entry's store instance: selection
|
||||
// writes land where the skeleton and details read.
|
||||
const conv = b.conversationSurface(ROOT)
|
||||
const conv = b.conversationApi(ROOT)
|
||||
expect(conv.instance).toBe(instance)
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.chatViewSurface(ROOT)
|
||||
const { injected } = b.chatViewApi(ROOT)
|
||||
injected.openFile('src/a.ts')
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
|
||||
@@ -239,11 +239,11 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('routes workspace switching through the runtime owner, carrying the draft', async () => {
|
||||
const b = await bench()
|
||||
const resident = b.residentSurface(ROOT)
|
||||
const resident = b.residentApi(ROOT)
|
||||
// Same-session connect (the picked workspace resolves to this session):
|
||||
// no draft movement, plain re-open.
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
const { state, actions } = b.inputSurface(ROOT)
|
||||
const { state, actions } = b.inputApi(ROOT)
|
||||
actions.setDraft('carry me')
|
||||
void resident.selectWorkspace('workspace-1' as never)
|
||||
await vi.waitFor(() => {
|
||||
@@ -261,7 +261,7 @@ describe('conversation slot inject surface', () => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('carry me')
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -269,7 +269,7 @@ describe('conversation slot inject surface', () => {
|
||||
const b = await bench()
|
||||
// No-session resident (hero before any session): connect resolves and
|
||||
// navigation proceeds without any draft choreography.
|
||||
const noSession = b.residentSurface(undefined)
|
||||
const noSession = b.residentApi(undefined)
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(ROOT))
|
||||
void noSession.selectWorkspace('workspace-0' as never)
|
||||
await vi.waitFor(() => {
|
||||
@@ -279,15 +279,15 @@ describe('conversation slot inject surface', () => {
|
||||
// Cross-session connect with an EMPTY draft: no move, no clearing.
|
||||
const OTHER = 'b9-other' as SessionId
|
||||
await b.runtime.sessions.add({ id: OTHER }, { current: false })
|
||||
const resident = b.residentSurface(ROOT)
|
||||
const { state } = b.inputSurface(ROOT)
|
||||
const resident = b.residentApi(ROOT)
|
||||
const { state } = b.inputApi(ROOT)
|
||||
expect(state.getSnapshot().draft).toBe('')
|
||||
b.runtime.workspaces.stub('connectWorkspace', () => Promise.resolve(OTHER))
|
||||
void resident.selectWorkspace('workspace-3' as never)
|
||||
await vi.waitFor(() => {
|
||||
expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [OTHER] })
|
||||
})
|
||||
expect(b.inputSurface(OTHER).state.getSnapshot().draft).toBe('')
|
||||
expect(b.inputApi(OTHER).state.getSnapshot().draft).toBe('')
|
||||
|
||||
// Connect failure: the rejection propagates to the caller (the view owns
|
||||
// the rollback) and no further navigation happens.
|
||||
@@ -310,7 +310,7 @@ describe('conversation slot inject surface', () => {
|
||||
|
||||
it('views read face projects the ring ledger (subscribe/version through ctx.slots)', async () => {
|
||||
const b = await bench()
|
||||
const { injected } = b.conversationSurface(ROOT)
|
||||
const { injected } = b.conversationApi(ROOT)
|
||||
const before = injected.views.version()
|
||||
const listener = vi.fn()
|
||||
const unsub = injected.views.subscribe(listener)
|
||||
@@ -332,7 +332,7 @@ describe('conversation slot inject surface', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('details inject surface', () => {
|
||||
describe('details inject API', () => {
|
||||
it('details injects the one layout callback; selection rides the shared store instead', async () => {
|
||||
const b = await bench()
|
||||
const entry = b.entryOf('details')
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md
|
||||
README.md: a53fb3a89eaee364cb025ca728ca42ce934887b0
|
||||
README.md: f0446aa0637bc181f7fdc22e5d0d3192e0ac20cf
|
||||
README.zh.md: 1ad9f50aee5b103f6455e4d4b7d29fa9eb29a108
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Goal surface plugin, browser half: the `GoalBar` strip is the second standalone card in the `conversation.input.dock` composer-context stack (order 10, after Todo and before Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear through `ctx.remote.goals` — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the rejected Remote error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-layout/README.md
|
||||
README.md: fa60520a20ac8a7f25d494879c68efb06a28998f
|
||||
README.md: c58b9a26ac794131aaa197722d14bd04de9f0cae
|
||||
README.zh.md: 2ce3d1972d39013d84216596dc57d48ab2d245d9
|
||||
|
||||
@@ -6,7 +6,7 @@ Shell plugin: three-column AppFrame (drag handles and concession chain) plus the
|
||||
|
||||
AppFrame always mounts the conversation and details columns; a connected Session renders through `SessionProvider`. The transient layout store starts the sidebar at its default width and details closed, and it never reads or writes `localStorage`. Hero and other unselected states also derive a zero rendered details width without changing that stored preference. AppFrame retains the last non-blank Session id across those states: the first Session remains closed, an explicit details action opens the contract default width, returning to the same Session restores its unchanged width, and selecting a different Session closes details before paint. The conversation owner share is empty, while the sidebar owner share contains only `collapsed` and `width`; registrants obtain business data from standard hooks and actions from their own inject faces.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `LayoutService`, and the four owner-share interfaces. AppFrame, the panel store, and the concession solver remain package-internal.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { createLayoutStore } from './stores.ts'
|
||||
import { LayoutService } from './service.ts'
|
||||
import { ThemePresenter } from './theme-presenter.ts'
|
||||
|
||||
// Contract surface only (export-convergence rule: cross-package consumers
|
||||
// Contract exports only (export-convergence rule: cross-package consumers
|
||||
// keep a symbol exported; test-only/package-internal symbols live off /src).
|
||||
// ILayout: the ctx.layout face consumers and test fakes type against.
|
||||
// OwnerShare contracts below are the render-side halves registrants compose
|
||||
@@ -64,7 +64,7 @@ export interface ConvOwnerProps {}
|
||||
/** Details owner share: empty — sessionId arrives as a framework-standard prop. */
|
||||
export interface DetailsOwnerProps {}
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
/** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
|
||||
export const inject = ['slots', 'theme']
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// ONE register() call declares the three child slots + seats the store factory
|
||||
// + wires the panel actions through the inject hook; teardown cascades
|
||||
// (service unprovided + declarations gone + registration cleared). Node half
|
||||
// and the invariant companion ride along — one-line surfaces the aggregate
|
||||
// and the invariant companion ride along — one line exposes the aggregate
|
||||
// coverage gate still requires exercised.
|
||||
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
@@ -111,7 +111,7 @@ describe('node half + invariant companion', () => {
|
||||
const register = vi.fn().mockReturnValue(() => {})
|
||||
const ctx = { invariants: { register } } as never
|
||||
// The /invariant subpath types live in lib/types (build product); assert
|
||||
// the surface so the call stays typed where lint runs without a build.
|
||||
// the API so the call stays typed where lint runs without a build.
|
||||
const dispose = await (invariant as { apply: (ctx: never) => Promise<() => void> }).apply(ctx)
|
||||
expect(register).toHaveBeenCalledWith('@deepseek-ai/dsh-client-ui-layout', expect.any(Function))
|
||||
// The installer is the declared no-op — calling it must not throw.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-model/README.md
|
||||
README.md: 519429834f214fcb82eeb692378fb79770fa30be
|
||||
README.md: fdc3258eb37b45f4773648d2d796c275f8657d47
|
||||
README.zh.md: 116e151d1afeaaa22618c408eed2c7542d1357e7
|
||||
|
||||
@@ -10,7 +10,7 @@ When the Host reports that no adapter serves the session's route (`session.model
|
||||
|
||||
Directories are per-session, resolved lazily through `ctx.models.directoryFor(sessionId)`, and disposed with the session scope. Addressed subagent sessions expose neither entry, and their directory rejects loads, selections, and reconnect refreshes, because ordinary Agent-bound model RPCs would activate persisted child history outside the direct-parent continuation path.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state shape, and the seat's injected face type.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `ModelService`, `ModelDirectory` with its state fields, and the seat's injected face type.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-models/README.md
|
||||
README.md: e0c5728d47e053df1934ef9eb69df3f8d985a4ec
|
||||
README.zh.md: fe11e6cdd190e19d5b5dac6dc95950ba59a3172b
|
||||
README.md: 89a253fca9f16ea5655cdd7536a441e98dcc4d3c
|
||||
README.zh.md: 350be495f1491738e6e861ba127b3b0070a5f073
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Models settings plugin: the provider configuration page and official-DeepSeek conditional onboarding step. It joins three wire domains into one shared snapshot — `llm.providers` (the configurable-provider directory with each route's live/dormant state), `settings.describe` (serialized schemas, layered redacted values, secret slots), and `credentials.describe` (value-free configured/source/writable badges) — and renders provider rows with one editor card at a time, without presenting route liveness as provider status.
|
||||
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint) and each adapter's model catalog. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
Rows are the *configured* providers (their profile resolves in the owning namespace); a whole-section provider whose key is not configured anywhere (the first-run DeepSeek posture) renders as its open setup card instead of a row, and the add flow is a card carrying the dormant-directory provider select — a bare-mounted `llm-pi-ai` offers its whole installed catalog before any route exists. The pi-ai card additionally edits that route's **model list** and can ask the provider what it serves. A row labels API-key state with a green solid dot only when a referenced credential is confirmed configured, and with a red solid dot only when a named reference is confirmed missing; reference-free provider-native authentication and unavailable credential enrichment remain unmarked. The editor is a hand-written card per adapter family: the primary field is a single **API key** input — the page never asks for an environment-variable name; a typed key stores **write-only** through `credentials.set` under the profile's reference, deriving `<ROUTE>_API_KEY` when the profile has none, and the pi-ai profile records that derivation as `apiKeyEnv`, so `settings.yaml` never carries a key value. Leaving a new pi-ai provider's key blank saves a reference-free profile and therefore preserves provider-native authentication such as the Bedrock credential chain or Vertex ADC. A successful Apply emits a local accessible status message without echoing secret material. The collapsed 自定义设置 fold carries the curated extras — `baseURL` for both families (the deepseek placeholder shows the public endpoint), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Those two are what a hand-declared route names for itself: the create card asks for both because nothing can default them, so the editor reaches both rather than leaving them to `settings.yaml`. Clearing the name unsets it and the route falls back to its id, which is what the placeholder shows; the protocol has no such fallback. A catalog route gets neither — it defaults its name from its catalog entry, and its models each carry their own protocol, so a route-level one could only override every one of them. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. Reasoning effort is deliberately NOT among them: it is a per-model capability and the models under one provider disagree about which levels they accept, so a provider-scoped control could only be set to a value some of them reject — which would hide even the models that support the level. The composer's model picker offers each model its own levels, and a switch there records provider, model, and effort together as the default for the next session. The profile field stays in `settings.yaml` for a deployment that knows its route. Each DeepSeek row edits `id`, optional display `name`, and optional `contextWindow`/`maxTokens`; existing fields outside that curated set survive edits, while every other profile field stays owned by `settings.yaml`. A row is deletable only when the user layer alone carries it (removal restores the composition base), and its localized confirmation dialog names the provider in the title, description, and final action. A row is tagged **Custom** when the directory entry says the owning adapter ships nothing under that key. The tag follows that answer alone: having a stored profile does not make a route custom — narrowing a shipped provider's models stores one too — and an adapter that reports nothing leaves its rows untagged rather than being read as shipped.
|
||||
|
||||
The DeepSeek step projects `deepseek-official` readiness from that same joined snapshot after earlier onboarding pages complete. It recognizes the official adapter through its `llm-deepseek` configurable-provider declaration, so an undeclared live route with the same provider id is not treated as repairable configuration. A configured credential reference completes the step without rendering, including a read-only launch-environment credential. Only a mounted, active adapter with a missing writable reference shows the page that opens Settings on Models, whose existing setup card exclusively owns key input and `credentials.set`; the step never holds a secret. An absent adapter, inactive route, failed join, read-only deployment, or unusable settings or credential capability completes the step without rendering so onboarding cannot block the product; Models remains the diagnostic surface.
|
||||
|
||||
@@ -28,7 +28,7 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). DeepSeek exposes `baseURL`, `reasoningEffort`, and model `id`/`name`/`contextWindow`/`maxTokens`; pi-ai exposes `baseURL` and `reasoning`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout ([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md)). Both families expose `baseURL` and model `id`/`name`/`contextWindow`/`maxTokens`; a hand-declared pi-ai route also exposes `displayName` and `api`. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `settings.yaml`; existing model fields the editor does not show are preserved. A profile schema without the conventional fields renders the hint alone, and the two curated layouts key on the `llm-deepseek`/`llm-pi-ai` namespaces by name.
|
||||
- **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `<ROUTE>_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them.
|
||||
- **Only pi-ai routes can be hand-declared** — the custom-provider card writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create.
|
||||
- **Interrogation covers OpenAI-compatible endpoints** — the adapter reads only that model-list response format, so a gateway speaking another protocol reports that it cannot be asked and its models are entered by hand.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
模型设置插件:提供方配置页和按条件显示的 DeepSeek 官方首次使用引导步骤。它把三个协议领域汇聚为一个共享快照:`llm.providers`(可配置提供方目录,含每条路由的存活/休眠状态)、`settings.describe`(序列化 schema、分层脱敏值、secret 槽位)与 `credentials.describe`(不含值的 configured/source/writable 徽标);页面据此渲染提供方行,一次只展开一张编辑卡片,且不把路由存活状态呈现为提供方状态。
|
||||
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点),以及各适配器自己的模型目录。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
行是*已配置*的提供方(其 profile 在所属 namespace 中解析得出);密钥未在任何地方配置的整分节提供方(DeepSeek 的首次运行姿态)会渲染为其展开的设置卡片而非一行,「新增」流程则是一张承载休眠目录提供方选择框的卡片——裸挂载的 `llm-pi-ai` 在任何路由存在之前就能提供其完整的已安装 catalog。pi-ai 卡片还会编辑该路由的**模型列表**,并可查询提供方所提供的模型。只有确认引用的凭据已配置时,行才会以绿色实心点标示 API 密钥状态;只有确认具名引用缺失时,才会以红色实心点标示。无引用的提供方原生认证以及无法取得凭据补充信息时都不显示状态点。编辑器是每个适配器家族各一张的手写卡片:主字段是单独一个 **API 密钥**输入框——页面从不询问环境变量名;键入的密钥经 `credentials.set` 以**只写**方式存入 profile 的引用之下,profile 没有引用时便派生 `<ROUTE>_API_KEY`,pi-ai profile 会把这次派生记录为 `apiKeyEnv`,因此 `settings.yaml` 从不携带密钥值。为新的 pi-ai 提供方留空密钥会保存一个不带引用的 profile,因此能保留提供方原生认证,例如 Bedrock 凭据链或 Vertex ADC。「应用」成功后会发出本地无障碍状态消息,且绝不回显任何机密内容。收起的「自定义设置」折叠区承载精选的额外字段——两个家族都有 `baseURL`(deepseek 的占位符显示公共端点)、各适配器自己的模型目录,以及适配器未提供的那类 pi-ai 路由的**显示名称**与 **API 协议**。这两个字段是手工声明路由为自己命名的东西:创建卡片之所以索要它们,正因为没有东西能为它们兜底,因此编辑器也够得着这两个,而不是把它们留给 `settings.yaml`。清空名称即取消设置,路由退回自己的 id——占位符显示的就是它;协议没有这样的兜底。内置目录路由两个都不给:它的名称由目录条目兜底,它的每个模型各自带着自己的协议,路由级协议只可能把它们全部覆盖掉。Provider ID 保持固定:它是 settings 的键、是其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。推理等级刻意**不在**其中:它是按模型的能力,而同一提供方下各模型接受的档位并不一致,因此提供方级的控件只可能被设成其中一些模型会拒绝的值——那会连支持该档位的模型也一并隐藏。输入框的模型选择器为每个模型提供它自己的档位,在那里切换会把提供方、模型、推理等级一并记为下一个会话的默认值。profile 字段仍留在 `settings.yaml`,供清楚自己路由的部署使用。每条 DeepSeek 模型行可编辑 `id`、可选的显示名称 `name` 与可选的 `contextWindow`/`maxTokens`;精选集合以外的现有字段会在编辑后保留,其余每个 profile 字段仍归 `settings.yaml` 所有。只有当某行仅由用户层承载时它才可删除(删除会还原组合 base),其本地化确认对话框会在标题、说明和最终操作中点名该提供方。当目录条目表明拥有该路由的适配器在这个键下什么都没有时,该行会带上 **自定义** 标签。标签只跟随这个答案:存了 profile 并不使一条路由成为自定义——收窄一个内置提供方的模型同样会存下 profile——而什么都不回答的适配器,其路由保持无标签,不会被当成内置。
|
||||
|
||||
前序首次使用引导页面完成后,DeepSeek 步骤会从同一个联接快照得出 `deepseek-official` 的就绪状态。它通过 `llm-deepseek` 的可配置提供方声明识别官方适配器,因此同 id 但未声明的存活路由不属于可修复配置。凭据引用已配置时,该步骤会直接完成而不渲染,其中包括来自启动环境且只读的凭据。只有已挂载且活跃、引用可写但尚未配置的适配器才会显示前往「设置」Models 分区的页面;密钥输入和 `credentials.set` 仅由该分区已有的设置卡片负责,该步骤绝不持有 secret。适配器缺失、路由不活跃、联接失败、部署只读或设置/凭据能力不可用时,该步骤均不渲染并直接完成,以免首次使用引导阻塞产品;Models 页仍是诊断界面。
|
||||
|
||||
@@ -28,7 +28,7 @@ pi-ai profile 的 `models` 列表就在卡片上编辑:一行一个模型,
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。DeepSeek 公开 `baseURL`、`reasoningEffort` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;pi-ai 公开 `baseURL` 与 `reasoning`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **卡片上可编辑的只有 API 密钥与精选折叠区字段**:手写编辑器用 schema 通用的字段覆盖面换来了设计稿上的布局([Agent Note](../../../.agents/notes/implemented/architecture/2026-07-30-web-config-plane.md))。两个家族都公开 `baseURL` 与模型的 `id`/`name`/`contextWindow`/`maxTokens`;手工声明的 pi-ai 路由还公开 `displayName` 与 `api`。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `settings.yaml` 中;编辑器未展示的现有模型字段会予以保留。不带这些约定字段的 profile schema 只渲染该提示,两套精选布局则以 `llm-deepseek`/`llm-pi-ai` 这两个 namespace 的名字为键。
|
||||
- **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `<ROUTE>_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据和无法识别的目标会保留,因为该行无法证明自己拥有它们。
|
||||
- **只有 pi-ai 路由可以手工声明**:自定义提供方卡片写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供方的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。
|
||||
- **询问只覆盖 OpenAI 兼容端点**:适配器只读这种模型列表响应格式,因此讲其他协议的网关会报告自己无法被询问,其模型需手工填写。
|
||||
|
||||
@@ -236,7 +236,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode {
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={styles['input']}
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={protocol}
|
||||
aria-label={t('customApi')}
|
||||
disabled={profileDisabled}
|
||||
|
||||
@@ -54,6 +54,8 @@ interface EditorTarget extends ProviderIdentity {
|
||||
settingsPath: readonly string[]
|
||||
/** Writable credential identified under this page's conventional reference. */
|
||||
credentialRef?: string
|
||||
/** The adapter reports this route as one it does not ship (see {@link ProviderEditorProps.declared}). */
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** Values that vary around the shared provider-editor rendering. */
|
||||
@@ -71,6 +73,7 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps):
|
||||
provider={target.provider}
|
||||
displayName={target.displayName}
|
||||
settingsPath={target.settingsPath}
|
||||
{...target.declared === true ? { declared: true } : {}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
@@ -135,6 +138,10 @@ function targetOf(row: ProviderRow): EditorTarget {
|
||||
settingsNs: row.entry.settingsNs,
|
||||
settingsPath: row.entry.settingsPath,
|
||||
...credentialRef === undefined ? {} : { credentialRef },
|
||||
// Absent is not "shipped": an adapter that answers nothing leaves the
|
||||
// route-level fields only a declared route owns off the card, exactly as
|
||||
// it leaves the custom tag off the row.
|
||||
...row.entry.declared === true ? { declared: true } : {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,8 +184,10 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
setAdding(false)
|
||||
setDeclaring(false)
|
||||
if (changed) {
|
||||
setSavedTarget(target)
|
||||
void controller.load()
|
||||
// Announced only once the refreshed directory is in the snapshot the
|
||||
// notice reads its name from: an apply can rename the route, and the
|
||||
// target captured when the card opened still carries the old name.
|
||||
void controller.load().then(() => { setSavedTarget(target) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +227,17 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
// The saved provider as the directory currently names it. The route id is
|
||||
// what the apply cannot change, so it is what the notice is keyed by; a row
|
||||
// the same apply removed keeps the captured identity, since nothing newer
|
||||
// exists to name it with.
|
||||
const savedRow = savedTarget === undefined
|
||||
? undefined
|
||||
: state.rows.find(row => row.entry.provider === savedTarget.provider)
|
||||
const savedIdentity = savedRow === undefined
|
||||
? savedTarget
|
||||
: { provider: savedRow.entry.provider, displayName: savedRow.entry.displayName }
|
||||
|
||||
const configured = state.rows.filter(row => row.configured)
|
||||
const addable = state.rows.filter(row => !row.configured && row.entry.settingsNs !== '')
|
||||
const addTarget = adding ? editing : undefined
|
||||
@@ -232,11 +252,11 @@ function Loaded({ injected }: { injected: ModelsSectionInjected }): ReactNode {
|
||||
<h2 className={styles['title']}>{t('title')}</h2>
|
||||
<p className={styles['intro']}>{t('intro')}</p>
|
||||
{!state.writable && state.status === 'ready' ? <p className={styles['notice']}>{t('readOnly')}</p> : null}
|
||||
{savedTarget === undefined
|
||||
{savedIdentity === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={styles['savedNotice']} role="status" aria-live="polite">
|
||||
{providerCopy(t('savedProvider'), savedTarget)}
|
||||
{providerCopy(t('savedProvider'), savedIdentity)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles['rows']}>
|
||||
|
||||
@@ -7,7 +7,10 @@
|
||||
* a key is entered; a blank key materializes a reference-free profile for
|
||||
* provider-native authentication);
|
||||
* the collapsed 自定义设置 area carries the per-family extras (`baseURL` for
|
||||
* both families and DeepSeek's id/name/context-window model catalog).
|
||||
* both families, DeepSeek's id/name/context-window model catalog, and the
|
||||
* display name and wire protocol of a pi-ai route the adapter does not ship —
|
||||
* the two fields the create card asked that route for, editable here for the
|
||||
* same reason).
|
||||
* Reasoning effort is deliberately absent: it is a per-MODEL capability, and
|
||||
* the models under one provider disagree about it, so a provider-scoped
|
||||
* control can only be set to a value some of them reject. The composer's
|
||||
@@ -30,7 +33,7 @@ import {
|
||||
import { apiKeyFailure } from './apiKey.ts'
|
||||
import { EditorFooter } from './EditorFooter.tsx'
|
||||
import { ModelListEditor } from './ModelListEditor.tsx'
|
||||
import { deriveKeyRef, messageOf } from './store.ts'
|
||||
import { deriveKeyRef, messageOf, protocolChoices } from './store.ts'
|
||||
import type { en } from './locales.ts'
|
||||
import styles from './ModelsSection.module.css'
|
||||
|
||||
@@ -48,6 +51,14 @@ export interface ProviderEditorProps {
|
||||
displayName: string
|
||||
/** Hide the title row (the add card renders its own provider select). */
|
||||
hideTitle?: boolean
|
||||
/**
|
||||
* Whether the adapter reports this route as hand-declared — absent from its
|
||||
* installed catalog. Such a route carries its own wire protocol, chosen when
|
||||
* it was created and editable here for the same reason; a catalog route's
|
||||
* models each carry theirs, so a route-level protocol there could only
|
||||
* override every one of them and the card does not offer it.
|
||||
*/
|
||||
declared?: boolean
|
||||
/** The owning namespace view (schema, layers, secrets). */
|
||||
namespace: SettingsNamespaceView
|
||||
/** Path from the section root to this provider's profile. */
|
||||
@@ -139,6 +150,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
const disabled = props.readOnly || busy
|
||||
const layout = layoutOf(namespace.ns)
|
||||
const keyRef = refFor(namespace, settingsPath, props.provider)
|
||||
// The same schema read the create card makes, so the choices offered here
|
||||
// and there cannot drift apart: both come from the adapter's own `Config`.
|
||||
// Only the pi-ai layout has a per-route protocol for the read to find, and
|
||||
// it rehydrates the whole section schema, so the other layouts skip it.
|
||||
const protocols = useMemo(
|
||||
() => layout === 'pi-ai' ? protocolChoices(namespace) : [],
|
||||
[layout, namespace],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let stale = false
|
||||
@@ -289,11 +308,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}
|
||||
|
||||
/**
|
||||
* The curated fields of one known adapter family. Taking the narrowed
|
||||
* family as a parameter is what makes `EFFORT_FIELD` total here: an
|
||||
* unknown namespace never reaches this body.
|
||||
* The curated fields of one known adapter family. The family arrives
|
||||
* narrowed so the per-family branches below are total: an unknown namespace
|
||||
* renders the hint instead and never reaches this body.
|
||||
*/
|
||||
const curatedFields = (family: 'deepseek' | 'pi-ai'): ReactNode => {
|
||||
// What a hand-declared route names for itself and nothing else can supply.
|
||||
// A whole-section `llm-deepseek` profile is a composition fact with no
|
||||
// per-route identity for its schema to carry, hence the family test.
|
||||
const ownsIdentity = family === 'pi-ai' && props.declared === true
|
||||
const customModels = getPath(draft, ['models'])
|
||||
const modelsOverridden = hasPath(draft, ['models'])
|
||||
const models = modelDrafts(modelsOverridden ? customModels : inheritedModels())
|
||||
@@ -334,6 +357,33 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
<details className={styles['customized']}>
|
||||
<summary className={styles['customizedSummary']}>{t('customized')}</summary>
|
||||
<div className={styles['customizedBody']}>
|
||||
{/* The name and the protocol are the create card's two remaining
|
||||
profile fields; a route the adapter ships defaults both from
|
||||
its catalog entry and neither belongs on its card. */}
|
||||
{ownsIdentity
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customDisplayName')}</span>
|
||||
<input
|
||||
className={styles['input']}
|
||||
type="text"
|
||||
value={stringAt(draft, 'displayName') ?? ''}
|
||||
// What this route is called the moment the field is
|
||||
// cleared, which is the layer beneath the one this field
|
||||
// edits: a `cordis.yml` may pin a name for a route the
|
||||
// catalog does not ship, and only when nothing does is
|
||||
// the answer the route id. Reading the effective value
|
||||
// instead would echo the stored override back as the
|
||||
// thing clearing restores.
|
||||
placeholder={stringAt(getPath(namespace.base, settingsPath), 'displayName')
|
||||
?? props.provider}
|
||||
aria-label={t('customDisplayName')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setField('displayName', event.target.value) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('baseUrl')}</span>
|
||||
<input
|
||||
@@ -350,6 +400,31 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* The protocol sits beside the endpoint it describes, as it does
|
||||
on the create card. */}
|
||||
{ownsIdentity
|
||||
? (
|
||||
<div className={styles['field']}>
|
||||
<span className={styles['fieldLabel']}>{t('customApi')}</span>
|
||||
<select
|
||||
className={`${styles['input']} ${styles['selectInput']}`}
|
||||
value={probeApi ?? ''}
|
||||
aria-label={t('customApi')}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { setField('api', event.target.value) }}
|
||||
>
|
||||
{/* A profile naming no protocol — hand-written into
|
||||
settings.yaml with no model to need one — selects
|
||||
nothing rather than reading as if it had picked the
|
||||
first choice. The option is named because a screen
|
||||
reader announces it either way, and an empty one is
|
||||
announced as a choice with no identity. */}
|
||||
{probeApi === undefined ? <option value="">{t('customApiUnset')}</option> : null}
|
||||
{protocols.map(choice => <option key={choice} value={choice}>{choice}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{/* Both families edit the same rows through the same contract; only
|
||||
the extras differ — DeepSeek's inherited capacities, pi-ai's
|
||||
endpoint interrogation. */}
|
||||
|
||||
@@ -80,6 +80,7 @@ export const en = {
|
||||
customRouteTaken: 'A provider already uses this ID.',
|
||||
customDisplayName: 'Display name',
|
||||
customApi: 'API protocol',
|
||||
customApiUnset: 'Not selected',
|
||||
customNeedsBaseUrl: 'A custom provider needs a base URL.',
|
||||
customNeedsModels: 'A custom provider needs at least one model.',
|
||||
create: 'Create provider',
|
||||
@@ -173,6 +174,7 @@ export const zh: typeof en = {
|
||||
customRouteTaken: '已有提供方使用了这个 ID。',
|
||||
customDisplayName: '显示名称',
|
||||
customApi: 'API 协议',
|
||||
customApiUnset: '未选择',
|
||||
customNeedsBaseUrl: '自定义提供方需要填写 API 地址。',
|
||||
customNeedsModels: '自定义提供方至少需要一个模型。',
|
||||
create: '创建提供方',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import { ModelsSection } from '../src/client/ModelsSection.tsx'
|
||||
import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx'
|
||||
import type { ModelsSectionInjected } from '../src/client/ModelsSection.tsx'
|
||||
import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx'
|
||||
import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx'
|
||||
@@ -47,6 +47,7 @@ function fail<T>(message: string, code: string): RpcResponse<T> {
|
||||
function piAiNamespace(
|
||||
providers: Record<string, unknown>,
|
||||
userProviders: Record<string, unknown> = providers,
|
||||
baseProviders: Record<string, unknown> = {},
|
||||
): SettingsNamespaceView {
|
||||
return {
|
||||
ns: 'llm-pi-ai',
|
||||
@@ -54,7 +55,7 @@ function piAiNamespace(
|
||||
// `value` is the effective section; `user` is only the layer this page
|
||||
// writes. They differ whenever a composition `base` supplies something.
|
||||
value: { providers },
|
||||
base: {},
|
||||
base: { providers: baseProviders },
|
||||
user: { providers: userProviders },
|
||||
applies: 'live',
|
||||
secrets: [],
|
||||
@@ -66,6 +67,8 @@ function scriptedFace(options: {
|
||||
providers?: Record<string, unknown>
|
||||
/** User layer, when it differs from the effective section. */
|
||||
userProviders?: Record<string, unknown>
|
||||
/** Composition layer, for a route a `cordis.yml` pins rather than the page. */
|
||||
baseProviders?: Record<string, unknown>
|
||||
/** Routes the adapter reports as hand-declared; the rest come back as shipped. */
|
||||
declaredRoutes?: readonly string[]
|
||||
discover?: ReturnType<typeof vi.fn>
|
||||
@@ -75,7 +78,7 @@ function scriptedFace(options: {
|
||||
const providers = options.providers ?? {
|
||||
openai: { apiKeyEnv: 'OPENAI_API_KEY', baseURL: 'https://proxy.example/v1' },
|
||||
}
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers)
|
||||
const namespace = piAiNamespace(providers, options.userProviders ?? providers, options.baseProviders ?? {})
|
||||
const discover = options.discover ?? vi.fn(() => Promise.resolve(ok({ models: [] })))
|
||||
const mutate = options.mutate ?? vi.fn(() => Promise.resolve(ok(namespace)))
|
||||
const set = options.set ?? vi.fn(() => Promise.resolve(ok({})))
|
||||
@@ -719,10 +722,152 @@ describe('hand-declared providers', () => {
|
||||
expect(fields()).toEqual([en.customRoute, en.customDisplayName, en.baseUrl, en.customApi, en.keyInput])
|
||||
cleanup()
|
||||
|
||||
// A shipped route's models each carry their own protocol, so its editor
|
||||
// offers no route-level protocol to override them with.
|
||||
await mountSection({ providers: { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } })
|
||||
openEditor('openai')
|
||||
fireEvent.click(screen.getByText(en.customized))
|
||||
expect(fields()).toEqual([en.keyInput, en.baseUrl])
|
||||
cleanup()
|
||||
|
||||
// A hand-declared route named its own protocol at creation, so editing it
|
||||
// reaches the same field the create card asked for.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
expect(fields()).toEqual([en.keyInput, en.customDisplayName, en.baseUrl, en.customApi])
|
||||
})
|
||||
|
||||
it('renames a declared route and falls back to its id when the name is cleared', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions', baseURL: 'https://acme.test/v1' },
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('Acme Gateway')
|
||||
// The route id, not the stored name: it is what the route will be called
|
||||
// the moment the field is cleared.
|
||||
expect(name.placeholder).toBe('acme-gateway')
|
||||
fireEvent.change(name, { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'set', path: ['providers', 'acme-gateway', 'displayName'], value: 'Acme 网关' }])
|
||||
})
|
||||
|
||||
it('offers the composition name as what a cleared field falls back to', async () => {
|
||||
// A `cordis.yml` can pin a route the catalog does not ship, so a declared
|
||||
// route's profile is not always the page's own. The field edits the user
|
||||
// layer alone, and clearing it restores the layer beneath — the
|
||||
// composition name here, not the route id — so that is what it offers.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } },
|
||||
baseProviders: { 'acme-gateway': { displayName: 'Acme (pinned)', api: 'openai-completions' } },
|
||||
userProviders: {},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const name = screen.getByLabelText<HTMLInputElement>(en.customDisplayName)
|
||||
expect(name.value).toBe('')
|
||||
expect(name.placeholder).toBe('Acme (pinned)')
|
||||
})
|
||||
|
||||
it('names the provider as the refreshed directory reports it after a rename', async () => {
|
||||
// The status line used to echo the target captured when the card opened,
|
||||
// which never lied while the name could not change. It can now.
|
||||
const { face } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
// The reload after the write answers with the renamed route, exactly as
|
||||
// the adapter re-registers it.
|
||||
face.llm.providers = vi.fn(() => Promise.resolve(ok({
|
||||
providers: [{
|
||||
provider: 'acme-gateway',
|
||||
displayName: 'Acme 网关',
|
||||
settingsNs: 'llm-pi-ai',
|
||||
settingsPath: ['providers', 'acme-gateway'],
|
||||
active: true,
|
||||
declared: true,
|
||||
}],
|
||||
})))
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: 'Acme 网关' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
const notice = await screen.findByRole('status')
|
||||
expect(notice.textContent).toBe(providerCopy(en.savedProvider, {
|
||||
provider: 'acme-gateway',
|
||||
displayName: 'Acme 网关',
|
||||
}))
|
||||
})
|
||||
|
||||
it('drops the stored name rather than storing an empty one the adapter refuses', async () => {
|
||||
// `llm-pi-ai` rejects an empty displayName outright, so clearing the field
|
||||
// must unset it — which is also what the user means: use the route id.
|
||||
const { mutate } = await mountSection({
|
||||
providers: { 'acme-gateway': { displayName: 'Acme Gateway', api: 'openai-completions' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
fireEvent.change(screen.getByLabelText(en.customDisplayName), { target: { value: ' ' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
expect(firstMutate(mutate).ops)
|
||||
.toEqual([{ op: 'unset', path: ['providers', 'acme-gateway', 'displayName'] }])
|
||||
})
|
||||
|
||||
it('edits the protocol a declared route was created with', async () => {
|
||||
const { mutate } = await mountSection({
|
||||
providers: {
|
||||
'acme-gateway': {
|
||||
apiKeyEnv: 'ACME_GATEWAY_API_KEY',
|
||||
api: 'openai-completions',
|
||||
baseURL: 'https://gateway.acme.example/v1',
|
||||
models: [{ id: 'acme-large' }],
|
||||
},
|
||||
},
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
const protocol = screen.getByLabelText<HTMLSelectElement>(en.customApi)
|
||||
expect(protocol.value).toBe('openai-completions')
|
||||
fireEvent.change(protocol, { target: { value: 'anthropic-messages' } })
|
||||
fireEvent.click(screen.getByText(en.apply))
|
||||
|
||||
await waitFor(() => { expect(mutate).toHaveBeenCalledTimes(1) })
|
||||
// Only the protocol travels: every other stored field is unchanged, so no
|
||||
// op restates it.
|
||||
expect(firstMutate(mutate)).toEqual({
|
||||
ns: 'llm-pi-ai',
|
||||
ops: [{ op: 'set', path: ['providers', 'acme-gateway', 'api'], value: 'anthropic-messages' }],
|
||||
expectedRevision: 3,
|
||||
})
|
||||
})
|
||||
|
||||
it('selects nothing for a declared route whose profile names no protocol', async () => {
|
||||
// A route hand-written into settings.yaml with no model needs no protocol
|
||||
// to resolve, so the card can be opened over one. The select must not read
|
||||
// as if that route had picked its first choice.
|
||||
await mountSection({
|
||||
providers: { 'acme-gateway': { baseURL: 'https://gateway.acme.example/v1' } },
|
||||
declaredRoutes: ['acme-gateway'],
|
||||
})
|
||||
openEditor('acme-gateway')
|
||||
|
||||
expect(screen.getByLabelText<HTMLSelectElement>(en.customApi).value).toBe('')
|
||||
})
|
||||
|
||||
it('retries only the key after the profile landed, and reports the provider on cancel', async () => {
|
||||
|
||||
@@ -61,6 +61,29 @@ describe('ModelsSection theme styles', () => {
|
||||
expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
|
||||
})
|
||||
|
||||
it('gives every dropdown the shared chevron instead of the OS arrow', () => {
|
||||
// `select.input` caps the control at 240px, and the OS arrow is painted
|
||||
// flush inside that shrunk right edge — visibly tighter than every other
|
||||
// control on the page. `.selectInput` is what removes it, reserves the
|
||||
// right pad, and paints the shared chevron; a `<select>` that takes
|
||||
// `.input` alone silently keeps the OS one.
|
||||
const sources = readdirSync(fileURLToPath(new URL('../src/client/', import.meta.url)))
|
||||
.filter(name => name.endsWith('.tsx'))
|
||||
.map(name => ({
|
||||
name,
|
||||
text: readFileSync(fileURLToPath(new URL(`../src/client/${name}`, import.meta.url)), 'utf8'),
|
||||
}))
|
||||
const bare = sources.flatMap(({ name, text }) => text
|
||||
.split('<select')
|
||||
.slice(1)
|
||||
// The element's own attributes end at the first `>`; a child `<option>`
|
||||
// carries no className of its own and must not answer for the select.
|
||||
.map(rest => rest.slice(0, rest.indexOf('>')))
|
||||
.filter(attributes => !attributes.includes('selectInput'))
|
||||
.map(() => name))
|
||||
expect(bare).toEqual([])
|
||||
})
|
||||
|
||||
it('never falls back to a literal colour', () => {
|
||||
// A token that resolves is never the problem; an undeclared one takes this
|
||||
// branch, and a literal here is a single colour for both themes.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-permission/README.md
|
||||
README.md: 742e82d767152073ab963dc74c0565d6e8f8e5c4
|
||||
README.md: cf3981d66745bd5a65c83e09daeb8463023f447b
|
||||
README.zh.md: 70bbbb2d14358cbe52a6fc27deb7ce01d5f3679b
|
||||
|
||||
@@ -6,7 +6,7 @@ Permission browser surfaces for two different lifetimes. The General-settings ro
|
||||
|
||||
The current-session surface remains a popupSelect DECORATION hung on the host `/permission` command (`ctx.command.decorate`). A decoration is not a second command — the host command keeps its slash-menu row, the argued path (`/permission <preset>` switches directly), and the durable lifecycle logging; the decoration replaces only the bare invocation with the picker: one flat preset list with the current value marked active and kebab-case preset names rendered as title-case labels (`workspace-write` → `Workspace Write`, the composer chip's display transform twin), where a pick submits the `/permission <preset>` command line. Options and the active mark read the session's `permissions` projection (the same host-computed select the composer chip renders), so both current-session surfaces share one read source and one write path, and the pushed projection frame is the single confirmation both follow. The decoration is available exactly while the projection key is present; a permission-less composition shows neither picker nor Settings row.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`).
|
||||
The `/client` exports are the plugin body (`apply`/`inject`).
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Hover/focus label bubble (figma tooltip pill: dark plate, white text).
|
||||
// TODO: interaction is a placeholder (horizontal overflow clamps, but there
|
||||
// is no vertical flip on viewport collision and no arrow) — visuals and
|
||||
// behavior get a proper pass later.
|
||||
// TODO: interaction is a placeholder (horizontal overflow clamps and a
|
||||
// vertical collision flips the bubble to the other side, but there is no
|
||||
// arrow) — visuals and behavior get a proper pass later.
|
||||
// The anchor is the child element itself (cloneElement, no wrapper node), so
|
||||
// attaching a tooltip never changes the anchor's layout context. The bubble is
|
||||
// position:fixed and coordinates come from the anchor's rect at show time, so
|
||||
@@ -33,10 +33,12 @@ type TooltipLabel = string | (() => string)
|
||||
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
|
||||
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
|
||||
* toggling never remounts it (which would cut its CSS transitions).
|
||||
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
|
||||
* half-viewport cap would render a slab wider than the surface the anchor sits on.
|
||||
* @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's.
|
||||
* @returns the cloned anchor plus a fixed-position bubble while hovered/focused.
|
||||
*/
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement<AnchorProps> }) {
|
||||
export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
|
||||
const anchor = useRef<HTMLElement | null>(null)
|
||||
// React 18 keeps the element's ref outside props; forward it so wrapping an
|
||||
// anchor in Tooltip never silently severs the owner's ref.
|
||||
@@ -46,33 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
if (typeof childRef === 'function') childRef(el)
|
||||
else if (childRef != null) (childRef as MutableRefObject<HTMLElement | null>).current = el
|
||||
}, [childRef])
|
||||
const [pos, setPos] = useState<{ x: number; y: number } | null>(null)
|
||||
// The anchor's edges rather than final coordinates: a vertical flip has to
|
||||
// re-derive the bubble's own top from the opposite edge.
|
||||
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null)
|
||||
// Where the bubble actually sits, which is the requested side until the
|
||||
// viewport refuses it.
|
||||
const [placement, setPlacement] = useState<TooltipSide>(side)
|
||||
const bubble = useRef<HTMLSpanElement | null>(null)
|
||||
const resolvedLabel = pos === null
|
||||
? null
|
||||
: typeof label === 'function' ? label() : label
|
||||
// Horizontal viewport clamp: fixed positioning knows nothing about edges, so
|
||||
// a centered bubble near the right edge would clip. Each measurement resets
|
||||
// the base position before applying a direct style offset, allowing a shorter
|
||||
// label or wider viewport to release a previous clamp without another render.
|
||||
const y = pos === null
|
||||
? 0
|
||||
: placement === 'right'
|
||||
? pos.top + (pos.bottom - pos.top) / 2
|
||||
: placement === 'top' ? pos.top - 8 : pos.bottom + 8
|
||||
const EDGE_MARGIN = 12
|
||||
// Viewport fit: fixed positioning knows nothing about edges, so a centered
|
||||
// bubble near the right edge would clip and a long label under an anchor low
|
||||
// on the page would run off the bottom. Horizontally the bubble slides back
|
||||
// inside; vertically it flips to the opposite side, which is the only move
|
||||
// that does not cover the anchor being read. Each measurement resets the base
|
||||
// position first, so a shorter label or a larger viewport releases a previous
|
||||
// adjustment without another render.
|
||||
useLayoutEffect(() => {
|
||||
if (pos === null) return
|
||||
const clamp = () => {
|
||||
const fit = () => {
|
||||
const el = bubble.current
|
||||
/* v8 ignore next -- pos is set only while the bubble is mounted. */
|
||||
if (el === null) return
|
||||
const EDGE_MARGIN = 12
|
||||
el.style.left = `${pos.x}px`
|
||||
const r = el.getBoundingClientRect()
|
||||
let dx = 0
|
||||
if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right
|
||||
if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left
|
||||
el.style.left = `${pos.x + dx}px`
|
||||
if (side === 'right') return
|
||||
// Flip only into a side that genuinely fits, so an anchor with room on
|
||||
// neither side keeps the requested placement instead of oscillating.
|
||||
const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN
|
||||
const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN
|
||||
if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top')
|
||||
if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom')
|
||||
}
|
||||
clamp()
|
||||
window.addEventListener('resize', clamp)
|
||||
return () => { window.removeEventListener('resize', clamp) }
|
||||
}, [pos, resolvedLabel])
|
||||
fit()
|
||||
window.addEventListener('resize', fit)
|
||||
return () => { window.removeEventListener('resize', fit) }
|
||||
}, [placement, pos, resolvedLabel, side])
|
||||
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
// Hover and focus are independent triggers: the bubble hides only after
|
||||
// BOTH clear (hovering away from a focused anchor must not drop it).
|
||||
@@ -100,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
/* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */
|
||||
if (el === null) return
|
||||
const r = el.getBoundingClientRect()
|
||||
setPos(side === 'right'
|
||||
? { x: r.right + 10, y: r.top + r.height / 2 }
|
||||
: side === 'top'
|
||||
? { x: r.left + r.width / 2, y: r.top - 8 }
|
||||
: { x: r.left + r.width / 2, y: r.bottom + 8 })
|
||||
// Every show starts from the requested side; the fit pass flips it only
|
||||
// where this anchor's position demands it.
|
||||
setPlacement(side)
|
||||
setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom })
|
||||
}
|
||||
const showAfterHoverDelay = () => {
|
||||
cancelShow()
|
||||
@@ -132,7 +153,13 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false,
|
||||
onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() },
|
||||
})}
|
||||
{pos !== null && (
|
||||
<span ref={bubble} className={css.bubble} data-side={side} style={{ left: pos.x, top: pos.y }} role="tooltip">
|
||||
<span
|
||||
ref={bubble}
|
||||
className={css.bubble}
|
||||
data-side={placement}
|
||||
style={{ left: pos.x, top: y, ...maxWidth === undefined ? {} : { maxWidth } }}
|
||||
role="tooltip"
|
||||
>
|
||||
{resolvedLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -15,7 +15,7 @@ export const name = 'client-ui-primitives-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: pure props-in React atoms with zero cordis surface —
|
||||
* No runtime invariant: pure props-in React atoms with no Cordis API —
|
||||
* no events, no services, no mutable cross-plugin state; rendering contracts
|
||||
* are asserted directly by this package's component specs.
|
||||
*/
|
||||
|
||||
@@ -95,6 +95,18 @@ describe('Tooltip', () => {
|
||||
const rect = (left: number, right: number): DOMRect =>
|
||||
({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) })
|
||||
|
||||
it('caps the bubble width where the label would otherwise slab across the surface', () => {
|
||||
render(
|
||||
<Tooltip label="A description long enough to need a cap" side="bottom" maxWidth={360}>
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
|
||||
// The stylesheet's half-viewport cap stays the default; this one overrides it.
|
||||
expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px')
|
||||
})
|
||||
|
||||
it('clamps a bubble overflowing the right viewport edge back inside', () => {
|
||||
const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100))
|
||||
try {
|
||||
@@ -161,19 +173,88 @@ describe('Tooltip', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */
|
||||
const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) =>
|
||||
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) {
|
||||
const [top, bottom] = this.getAttribute('role') === 'tooltip'
|
||||
? [0, bubbleHeight]
|
||||
: [anchorTop, anchorBottom]
|
||||
return {
|
||||
left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}),
|
||||
}
|
||||
})
|
||||
|
||||
it('supports top placement for anchors at the viewport bottom', () => {
|
||||
render(
|
||||
<Tooltip label="Above" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
// jsdom rects are all-zero: top placement lands at the -8 gutter and the
|
||||
// zero-width measured rect clamps left to the 12px edge margin.
|
||||
expect(bubble.style.left).toBe('12px')
|
||||
expect(bubble.style.top).toBe('-8px')
|
||||
const spy = placed(700, 720, 20)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Above" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
// There is room above, so the requested side stands: the bubble's own
|
||||
// top sits at the anchor's top less the 8px gutter.
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
expect(bubble.style.top).toBe('692px')
|
||||
expect(bubble.style.left).toBe('150px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('flips a bottom bubble above an anchor with no room below', () => {
|
||||
// jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at
|
||||
// 700 would run off, and there is room for it above.
|
||||
const spy = placed(600, 700, 300)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Tall" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('top')
|
||||
expect(bubble.style.top).toBe('592px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('flips a top bubble below an anchor with no room above', () => {
|
||||
const spy = placed(10, 40, 100)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Tall" side="top">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
const bubble = screen.getByRole('tooltip')
|
||||
expect(bubble.getAttribute('data-side')).toBe('bottom')
|
||||
expect(bubble.style.top).toBe('48px')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the requested side when neither side fits', () => {
|
||||
// A bubble taller than the viewport has no home; oscillating between the
|
||||
// two would be worse than honouring the request.
|
||||
const spy = placed(300, 400, 900)
|
||||
try {
|
||||
render(
|
||||
<Tooltip label="Huge" side="bottom">
|
||||
<button type="button">anchor</button>
|
||||
</Tooltip>,
|
||||
)
|
||||
fireEvent.mouseEnter(screen.getByText('anchor'))
|
||||
expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom')
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-sidebar/README.md
|
||||
README.md: 45ae267d98b17bbc612cf932f5b95b42ba6ff4bf
|
||||
README.md: 4eb9eeb73f1f8398eb9d16434996840182ba79a9
|
||||
README.zh.md: a9fb927305d0bab5fb4d27adbfdbec90dfa1dd6d
|
||||
|
||||
@@ -12,7 +12,7 @@ Scrollbars in the column are a pointer affordance: the shell rebinds ui-theme's
|
||||
|
||||
The foot is the `sidebar.settings` seat: the sidebar renders only the bottom-pinned layout slot and shares its column state (`wide`); ui-settings registers the trigger row and settings panel there.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`) plus the contract types only; SidebarRoot, the row components, and the tree derivation remain package-internal behind the slot registration.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-skill/README.md
|
||||
README.md: 36b4cf4181d74ca1ea05fd8ed2db5e42fa36c7f2
|
||||
README.md: 0456db4de9453e5060e39b5f061422486e44dfc9
|
||||
README.zh.md: 336f43117e7bc4de41a31e636ee0966e5d1a2cd6
|
||||
|
||||
@@ -8,7 +8,7 @@ A pick lands the literal `/name ` text and the prompt ships the same literal ([s
|
||||
|
||||
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
|
||||
|
||||
## Skill tool row
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
|
||||
README.md: 3f97351bea04dbc268e6acc105be10e1e4e0a4b5
|
||||
README.md: e6c9c3240d03a0cde57eda0bce2a995f91786f4e
|
||||
README.zh.md: cf9e42d419cb472509a74ba227990b1502b38a75
|
||||
|
||||
@@ -8,7 +8,7 @@ Layering: `src/core/` is the pure core — `detectTrigger`, `menuReduce`/`seedGr
|
||||
|
||||
MenuView renders the menu store into the `conversation.input.overlay` slot (list kind, session scope) and renders null while closed. Typed triggers seed every source registered for that trigger; a programmatic launcher seeds only its requested source and publishes the source name through the controller's `launcher` snapshot store until the menu closes or typed tracking resumes. Groups sort by the optional `SlashSource.order` (lower first, default 0, ties keep registration order) under title rows localized through the `slash.menu` locale namespace (an unknown source shows its raw name); the list height clamps to the space above the composer, and a pointer down outside both the menu and the surrounding composer card dismisses it. The slot is owned by ui-conversation's composer entry (anchor, children declaration, lifecycle); its SlotMap type merge lives in this package's `src/client/slots.ts` because the dependency direction (ui-conversation → ui-slash) admits no reverse type import. Combobox pattern: focus stays in the textarea, rows pick on mousedown, the highlight rides `aria-activedescendant`.
|
||||
|
||||
The `/client` export surface is the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
|
||||
The `/client` exports are the plugin body (`apply`/`inject`), `SlashService`, `MenuViewInjected`, and the contract types. MenuView itself is internal — the slot registration closes over it.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface LocaleFace extends HostObservable<{ revision: number }> {
|
||||
bind(ns: string): Translate
|
||||
}
|
||||
|
||||
/** Minimal observable surface for host-provided standard-kit data sources. */
|
||||
/** Minimal observable API for host-provided standard-kit data sources. */
|
||||
export interface HostObservable<T> {
|
||||
getSnapshot(): T
|
||||
subscribe(fn: () => void): () => void
|
||||
@@ -97,7 +97,7 @@ export interface RenderOpts {
|
||||
hookContext?: unknown
|
||||
}
|
||||
|
||||
/** Host surface the runtime SlotsService presents to the installed renderer. */
|
||||
/** Host API the runtime SlotsService presents to the installed renderer. */
|
||||
export interface SlotRendererHost {
|
||||
/**
|
||||
* Subscribe to a key's registration changes (microtask-batched).
|
||||
@@ -167,8 +167,8 @@ export interface SlotRendererHost {
|
||||
/** The installation contract: runtime owns install()/renderSlot(); web-react implements rendering. */
|
||||
export interface SlotRenderer {
|
||||
/**
|
||||
* Render the root slot tree over the host surface (the only ctx-level entry).
|
||||
* @param host - the installing service's host surface.
|
||||
* Render the root slot tree over the host API (the only ctx-level entry).
|
||||
* @param host - the installing service's host API.
|
||||
* @param ownerProps - owner props from the shell's renderSlot('root', ...) call.
|
||||
* @returns the rendered tree.
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// SlotCore terminal-design behavior: the single register composition API —
|
||||
// a-priori 'root', children declaration/authorization, load-time validation,
|
||||
// one-axis lifecycle cascade, store scope pinning, subscription surface.
|
||||
// one-axis lifecycle cascade, store scope pinning, subscription API.
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
@@ -230,7 +230,7 @@ describe('store scope pinning', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('subscription surface', () => {
|
||||
describe('subscription API', () => {
|
||||
it('tracks declaration epochs separately from ordinary entry mutations', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.declarationEpoch('root')).toBe(1)
|
||||
|
||||
@@ -5,8 +5,8 @@ import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface SlotMap {
|
||||
'surface.a': { kind: 'single'; scope: 'root' }
|
||||
'surface.b': { kind: 'single'; scope: 'root' }
|
||||
'dynamic.a': { kind: 'single'; scope: 'root' }
|
||||
'dynamic.b': { kind: 'single'; scope: 'root' }
|
||||
'surface.injected': { kind: 'single'; scope: 'root'; inject: { token: string } }
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,17 @@ const Comp: SlotComponent<object> = () => null
|
||||
describe('dynamic-key escape hatch', () => {
|
||||
it('specDynamic reads wide-typed specs for string keys; undefined while undeclared', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.specDynamic('surface.a')).toBeUndefined()
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.specDynamic('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.specDynamic('dynamic.a')).toBeUndefined()
|
||||
core.register({ name: 'root', children: { 'dynamic.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.specDynamic('dynamic.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.specDynamic('never.declared')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('spec() narrows by SlotMap key', () => {
|
||||
const core = new SlotCore()
|
||||
core.register({ name: 'root', children: { 'surface.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.spec('surface.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.spec('surface.b')).toBeUndefined()
|
||||
core.register({ name: 'root', children: { 'dynamic.a': { kind: 'single', scope: 'root' } } }, Comp as never)
|
||||
expect(core.spec('dynamic.a')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(core.spec('dynamic.b')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('records the parent-declared Slot inject on the runtime spec', () => {
|
||||
@@ -41,9 +41,9 @@ describe('dynamic-key escape hatch', () => {
|
||||
|
||||
it('entries/getVersion on an untouched key return the frozen empty array and 0', () => {
|
||||
const core = new SlotCore()
|
||||
expect(core.entries('surface.b')).toHaveLength(0)
|
||||
expect(core.entries('surface.b')).toBe(core.entries('surface.b'))
|
||||
expect(core.getVersion('surface.b')).toBe(0)
|
||||
expect(core.entries('dynamic.b')).toHaveLength(0)
|
||||
expect(core.entries('dynamic.b')).toBe(core.entries('dynamic.b'))
|
||||
expect(core.getVersion('dynamic.b')).toBe(0)
|
||||
})
|
||||
|
||||
it('isLive is false for entries the core never held', () => {
|
||||
@@ -103,7 +103,7 @@ declare function NarrowTakeover(props: PropsRuntime<'chain.takeover'> & { matche
|
||||
|
||||
describe('terminal-design type chain', () => {
|
||||
it('holds the positive chain and the compile-time negatives', () => {
|
||||
// Everything below is compile-surface only.
|
||||
// Everything below is compile-time only.
|
||||
const samples = (core: SlotCore, chat: ChatHandle, fp: FrameProps, cp: ConvProps, acts: BoundActions<ChatHandle>) => {
|
||||
// ── positive chain ─────────────────────────────────────────────
|
||||
// Frame: children + factory store + inject; actions arrive baked.
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-theme/README.md
|
||||
README.md: 7b81728b02c0dadcbd134a4bca20c49721a3dd52
|
||||
README.md: cab9961a6d703d600a856e71339cda7062d20d62
|
||||
README.zh.md: 81b64c356749b6ffe12694b218e92eaa483ff739
|
||||
|
||||
@@ -20,5 +20,5 @@ None; this package neither assembles nor sends a provider request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
|
||||
- **Third-party themes are an extension point, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete.
|
||||
- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins. Design-owner-approved additions are the exception and enter as a static step plus a semantic alias in the same change (`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`).
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-trajectory/README.md
|
||||
README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4
|
||||
README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0
|
||||
README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d
|
||||
README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents/<id>/session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/<attachmentId>.<ext>`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8.
|
||||
|
||||
## Model Experience
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents/<id>/session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/<attachmentId>.<ext>` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"dsh": {
|
||||
"client": {
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
@@ -49,6 +50,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
@@ -60,6 +62,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
|
||||
@@ -156,8 +156,8 @@ type DetailTab =
|
||||
| 'tools'
|
||||
| 'overview'
|
||||
| 'rendered'
|
||||
| 'raw'
|
||||
| 'source'
|
||||
| 'origin'
|
||||
| 'input'
|
||||
| 'output'
|
||||
| 'schema'
|
||||
@@ -800,7 +800,7 @@ function RequestOptions({
|
||||
)
|
||||
}
|
||||
|
||||
function messageOriginLabel(source: unknown): string {
|
||||
function messageSourceLabel(source: unknown): string {
|
||||
if (typeof source !== 'object' || source === null || Array.isArray(source)) {
|
||||
return 'Unknown'
|
||||
}
|
||||
@@ -823,16 +823,16 @@ function messageOriginLabel(source: unknown): string {
|
||||
return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}`
|
||||
}
|
||||
|
||||
function MessageOrigin({ record }: { record: TableRecord }) {
|
||||
function MessageSource({ record }: { record: TableRecord }) {
|
||||
const source = record.cell.messageSource
|
||||
if (source === undefined) return <p className={css.noPayload}>Origin not recorded</p>
|
||||
if (source === undefined) return <p className={css.noPayload}>Source not recorded</p>
|
||||
const data = typeof source === 'object' && source !== null
|
||||
? source
|
||||
: { value: source }
|
||||
return (
|
||||
<JsonTree
|
||||
data={data}
|
||||
label="Message origin JSON"
|
||||
label="Message source JSON"
|
||||
className={css.jsonPayload}
|
||||
/>
|
||||
)
|
||||
@@ -897,17 +897,17 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] {
|
||||
if (record.cell.kind === 'compacted') {
|
||||
return [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'source', label: 'Raw Output' },
|
||||
{ id: 'raw', label: 'Raw Output' },
|
||||
]
|
||||
}
|
||||
if (isMarkdownRecord(record)) {
|
||||
return [
|
||||
{ id: 'overview', label: 'Summary' },
|
||||
{ id: 'rendered', label: 'Preview' },
|
||||
{ id: 'source', label: 'Source' },
|
||||
{ id: 'raw', label: 'Raw' },
|
||||
...(record.cell.messageSource === undefined
|
||||
? []
|
||||
: [{ id: 'origin', label: 'Origin' } as const]),
|
||||
: [{ id: 'source', label: 'Source' } as const]),
|
||||
]
|
||||
}
|
||||
return [
|
||||
@@ -2855,14 +2855,14 @@ export function TrajectoryTable({
|
||||
>
|
||||
{selected.cell.messageSource !== undefined && (
|
||||
<div>
|
||||
<dt>Origin</dt>
|
||||
<dt>Source</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.overviewHierarchyNavLink}
|
||||
onClick={() => { activateTab('origin') }}
|
||||
onClick={() => { activateTab('source') }}
|
||||
>
|
||||
<span>{messageOriginLabel(selected.cell.messageSource)}</span>
|
||||
<span>{messageSourceLabel(selected.cell.messageSource)}</span>
|
||||
<IconChevronRightOutline14
|
||||
className={css.overviewHierarchyJumpIconTight}
|
||||
size={11}
|
||||
@@ -2875,7 +2875,7 @@ export function TrajectoryTable({
|
||||
<div>
|
||||
<dt>
|
||||
{selectedAssistantRequestTarget !== undefined
|
||||
? 'Origin'
|
||||
? 'Source'
|
||||
: 'Hierarchy'}
|
||||
</dt>
|
||||
<dd className={css.overviewParentLinks}>
|
||||
@@ -2999,7 +2999,7 @@ export function TrajectoryTable({
|
||||
onOpenCall={openCallSummary}
|
||||
/>
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'source' && (
|
||||
{!promptSelected && selected !== undefined && activeTab === 'raw' && (
|
||||
<MarkdownRecordContent
|
||||
record={selected}
|
||||
rendered={false}
|
||||
@@ -3008,8 +3008,8 @@ export function TrajectoryTable({
|
||||
onOpenCall={openCallSummary}
|
||||
/>
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'origin' && (
|
||||
<MessageOrigin record={selected} />
|
||||
{!promptSelected && selected !== undefined && activeTab === 'source' && (
|
||||
<MessageSource record={selected} />
|
||||
)}
|
||||
{!promptSelected && selected !== undefined && activeTab === 'input' && (
|
||||
<RecordPayload record={selected} direction="input" />
|
||||
|
||||
@@ -164,6 +164,46 @@
|
||||
font: 14px/14px var(--ds-font-family-code);
|
||||
}
|
||||
|
||||
.export {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
height: 20px;
|
||||
padding: 0 7px;
|
||||
gap: 4px;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font: var(--dsw-font-xxs-12);
|
||||
}
|
||||
|
||||
.export:hover:not(:disabled) {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.export:focus-visible {
|
||||
outline: 1px solid var(--dsw-alias-state-business-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.export:disabled {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.exportIcon {
|
||||
flex: none;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.search {
|
||||
display: flex;
|
||||
flex: 0 1 164px;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Trajectory toolbar: timeline and ledger fold controls. */
|
||||
|
||||
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { NS } from './locales.ts'
|
||||
import css from './TrajectoryToolbar.module.css'
|
||||
|
||||
export interface TrajectoryToolbarProps {
|
||||
@@ -24,6 +26,14 @@ export interface TrajectoryToolbarProps {
|
||||
searchQuery: string
|
||||
/** Update the live ledger search query. */
|
||||
onSearchQueryChange: (query: string) => void
|
||||
/** Whether the session-log export is in flight. */
|
||||
exporting: boolean
|
||||
/** Trigger the session-log export download. */
|
||||
onExport: () => void
|
||||
/** Export failure message, shown while set; null while idle or successful. */
|
||||
exportError: string | null
|
||||
/** Translate a toolbar dictionary key. */
|
||||
t: TranslateNS<typeof NS>
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,17 +52,21 @@ export function TrajectoryToolbar({
|
||||
onToggleAllAssistants,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
exporting,
|
||||
onExport,
|
||||
exportError,
|
||||
t,
|
||||
}: TrajectoryToolbarProps) {
|
||||
return (
|
||||
<div className={css.root} role="toolbar" aria-label="Trajectory toolbar">
|
||||
<div className={css.root} role="toolbar" aria-label={t('toolbar.aria')}>
|
||||
<div className={css.inner}>
|
||||
<div className={css.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.toggle}
|
||||
aria-label="Use actual duration"
|
||||
aria-label={t('toolbar.useActualDuration')}
|
||||
aria-pressed={actualDuration}
|
||||
title={actualDuration ? 'Use equal-width operations' : 'Use actual duration'}
|
||||
title={actualDuration ? t('toolbar.useEqualWidth') : t('toolbar.useActualDuration')}
|
||||
onClick={() => { onActualDurationChange(!actualDuration) }}
|
||||
>
|
||||
<svg
|
||||
@@ -64,7 +78,7 @@ export function TrajectoryToolbar({
|
||||
<circle cx="8" cy="8" r="5.25" />
|
||||
<path d="M8 4.75V8l2.25 1.5" />
|
||||
</svg>
|
||||
Duration
|
||||
{t('toolbar.duration')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -74,7 +88,7 @@ export function TrajectoryToolbar({
|
||||
hidden
|
||||
onClick={() => { onActualTimeChange(!actualTime) }}
|
||||
>
|
||||
<span>Actual time</span>
|
||||
<span>{t('toolbar.actualTime')}</span>
|
||||
<span className={css.controlTrack} data-on={actualTime || undefined} aria-hidden="true">
|
||||
<span className={css.controlThumb} />
|
||||
</span>
|
||||
@@ -82,28 +96,42 @@ export function TrajectoryToolbar({
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
aria-label={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
|
||||
aria-pressed={allTurnsCollapsed}
|
||||
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
|
||||
title={allTurnsCollapsed ? t('toolbar.expandTurns') : t('toolbar.collapseTurns')}
|
||||
onClick={onToggleAllTurns}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allTurnsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Turns
|
||||
{t('toolbar.turns')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.action}
|
||||
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
aria-label={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
|
||||
aria-pressed={allAssistantsCollapsed}
|
||||
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
|
||||
title={allAssistantsCollapsed ? t('toolbar.expandCalls') : t('toolbar.collapseCalls')}
|
||||
onClick={onToggleAllAssistants}
|
||||
>
|
||||
<span className={css.actionIcon} aria-hidden="true">
|
||||
{allAssistantsCollapsed ? '⊞' : '⊟'}
|
||||
</span>
|
||||
Calls
|
||||
{t('toolbar.calls')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.export}
|
||||
aria-label={t('toolbar.exportAria')}
|
||||
title={exportError ?? (exporting ? t('toolbar.exporting') : t('toolbar.exportTitle'))}
|
||||
disabled={exporting}
|
||||
onClick={onExport}
|
||||
>
|
||||
<svg className={css.exportIcon} viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M8 3v7m0 0 3-3m-3 3L5 7" />
|
||||
<path d="M3 11.5V13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.5" />
|
||||
</svg>
|
||||
{t('toolbar.export')}
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.search}>
|
||||
@@ -111,8 +139,8 @@ export function TrajectoryToolbar({
|
||||
<input
|
||||
type="search"
|
||||
className={css.searchInput}
|
||||
aria-label="Search trajectory"
|
||||
placeholder="Search"
|
||||
aria-label={t('toolbar.search')}
|
||||
placeholder={t('toolbar.searchPlaceholder')}
|
||||
value={searchQuery}
|
||||
onChange={(event) => { onSearchQueryChange(event.currentTarget.value) }}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type {
|
||||
AssistantBlock, AssistantMessageNode, ConversationSnapshot,
|
||||
SnapshotStore,
|
||||
@@ -71,6 +71,8 @@ export interface TrajectoryViewInjected {
|
||||
}
|
||||
loadOlder: () => Promise<boolean>
|
||||
setActualDuration: (actualDuration: boolean) => void
|
||||
/** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */
|
||||
exportLog: () => Promise<void>
|
||||
}
|
||||
|
||||
interface UsageLike {
|
||||
@@ -118,9 +120,9 @@ function addUsage(
|
||||
}
|
||||
|
||||
export function TrajectoryView({
|
||||
useSession, useDuration, loadOlder, setActualDuration,
|
||||
inspect, onInspectDone,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
|
||||
useSession, useDuration, loadOlder, setActualDuration, exportLog,
|
||||
inspect, onInspectDone, t,
|
||||
}: ConvViewProps & InjectFace<TrajectoryViewInjected> & PropsLocale<'trajectory'>) {
|
||||
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_TURN_IDS)
|
||||
const [collapsedAssistants, setCollapsedAssistants] =
|
||||
useState<ReadonlySet<string>>(EMPTY_RECORD_IDS)
|
||||
@@ -128,6 +130,8 @@ export function TrajectoryView({
|
||||
const actualDuration = useDuration(value => value)
|
||||
const [actualTime, setActualTime] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [exporting, setExporting] = useState(false)
|
||||
const [exportError, setExportError] = useState<string | null>(null)
|
||||
const [searchIndex] = useState(() => new TrajectorySearchIndex())
|
||||
const [searchIndexRevision, setSearchIndexRevision] = useState(0)
|
||||
const searchIndexTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
@@ -443,6 +447,19 @@ export function TrajectoryView({
|
||||
return loadOlder()
|
||||
}, [loadOlder])
|
||||
|
||||
const onExport = useCallback(() => {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
setExportError(null)
|
||||
void exportLog().then(
|
||||
() => { setExporting(false) },
|
||||
(error: unknown) => {
|
||||
setExportError(error instanceof Error ? error.message : String(error))
|
||||
setExporting(false)
|
||||
},
|
||||
)
|
||||
}, [exportLog, exporting])
|
||||
|
||||
return (
|
||||
<div className={css.root} data-conversation-composer-overlay="">
|
||||
<TrajectoryToolbar
|
||||
@@ -462,7 +479,16 @@ export function TrajectoryView({
|
||||
onToggleAllAssistants={toggleAllAssistants}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
exporting={exporting}
|
||||
onExport={onExport}
|
||||
exportError={exportError}
|
||||
t={t}
|
||||
/>
|
||||
{exportError !== null && (
|
||||
<div className={css.exportError} role="alert">
|
||||
{exportError}
|
||||
</div>
|
||||
)}
|
||||
<TrajectoryTimeline
|
||||
turns={timelineTurns}
|
||||
mode={timelineMode}
|
||||
|
||||
42
packages/client/ui-trajectory/src/client/export-log.ts
Normal file
42
packages/client/ui-trajectory/src/client/export-log.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Session log export: browser download of the host-streamed ZIP. The archive
|
||||
* itself is produced and streamed by the host (GET /api/session.export); this
|
||||
* module only derives the download filename and triggers the browser save.
|
||||
* @module
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collapse an untrusted session id into one safe path/filename segment.
|
||||
* Distinct ids may collapse onto one segment (impossible for the host-minted
|
||||
* UUIDs, so no uniqueness suffix is kept).
|
||||
* @param id - the raw session id.
|
||||
* @returns a filesystem-safe single segment.
|
||||
*/
|
||||
function safeSessionIdSegment(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* The export archive filename for one session (same convention the host's
|
||||
* Content-Disposition uses).
|
||||
* @param sessionId - the root session id.
|
||||
* @returns the download filename.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: string): string {
|
||||
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a browser download of a blob response.
|
||||
* @param blob - the response body to save (passed straight through, no copy).
|
||||
* @param filename - the download filename.
|
||||
*/
|
||||
export function downloadBlob(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
anchor.click()
|
||||
// Revoke one tick later: some browsers read the blob URL after click().
|
||||
setTimeout(() => { URL.revokeObjectURL(url) }, 0)
|
||||
}
|
||||
@@ -4,20 +4,24 @@
|
||||
*/
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
// owning package) must be in the program for the register calls to type.
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { createTrajectoryDurationStore } from './duration-store.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
import { downloadBlob, sessionLogZipFilename } from './export-log.ts'
|
||||
import { en, NS, zh } from './locales.ts'
|
||||
import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts'
|
||||
import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts'
|
||||
import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts'
|
||||
import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts'
|
||||
import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts'
|
||||
import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts'
|
||||
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
|
||||
|
||||
/** Required services: the conversation slot, registries, and ordinary Session paging. */
|
||||
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions']
|
||||
/** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */
|
||||
export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the trajectory view tab. The registration
|
||||
@@ -25,6 +29,11 @@ export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sess
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries')
|
||||
// Registration-time text (the view tab label) reads through the bound
|
||||
// translate as a thunk, so it follows the active locale without
|
||||
// re-registration.
|
||||
const t = ctx.locale.bind(NS)
|
||||
const duration = createTrajectoryDurationStore()
|
||||
registerTrajectoryMessageDefinitions(ctx)
|
||||
registerTrajectoryRequestHeaderDefinition(ctx)
|
||||
@@ -36,7 +45,8 @@ export function apply(ctx: Context): void {
|
||||
name: 'conversation.view',
|
||||
id: 'trajectory',
|
||||
order: 10,
|
||||
label: 'Trajectory',
|
||||
locale: NS,
|
||||
label: () => t('view.trajectory'),
|
||||
inject: (sessionId: SessionId): TrajectoryViewInjected => {
|
||||
const session = ctx.sessions.binding(sessionId)?.session
|
||||
if (session === undefined) {
|
||||
@@ -50,6 +60,23 @@ export function apply(ctx: Context): void {
|
||||
return session.getSnapshot().views.get('trajectory') !== before
|
||||
},
|
||||
setActualDuration: (value) => { duration.set(value) },
|
||||
exportLog: async () => {
|
||||
// The host streams the ZIP (root + descendant artifacts verbatim)
|
||||
// from GET /api/session.export; the browser downloads the response.
|
||||
// A null origin (no-location Node contexts) falls back like the
|
||||
// carrier's resolveBase so the URL stays valid.
|
||||
const loc = (globalThis as { location?: { origin?: string } }).location
|
||||
const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal'
|
||||
const url = new URL('/api/session.export', origin)
|
||||
url.searchParams.set('sessionId', sessionId)
|
||||
url.searchParams.set('includeDescendants', 'true')
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
const detail = await response.text().catch(() => '')
|
||||
throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`)
|
||||
}
|
||||
downloadBlob(await response.blob(), sessionLogZipFilename(sessionId))
|
||||
},
|
||||
}
|
||||
},
|
||||
}, TrajectoryView))
|
||||
|
||||
76
packages/client/ui-trajectory/src/client/locales.ts
Normal file
76
packages/client/ui-trajectory/src/client/locales.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */
|
||||
|
||||
/** Dictionary namespace owned by this plugin. */
|
||||
export const NS = 'trajectory'
|
||||
|
||||
/** The trajectory dictionary key set (the source of truth for both locales). */
|
||||
export type TrajectoryKey =
|
||||
| 'view.trajectory'
|
||||
| 'toolbar.aria'
|
||||
| 'toolbar.duration'
|
||||
| 'toolbar.useActualDuration'
|
||||
| 'toolbar.useEqualWidth'
|
||||
| 'toolbar.actualTime'
|
||||
| 'toolbar.turns'
|
||||
| 'toolbar.expandTurns'
|
||||
| 'toolbar.collapseTurns'
|
||||
| 'toolbar.calls'
|
||||
| 'toolbar.expandCalls'
|
||||
| 'toolbar.collapseCalls'
|
||||
| 'toolbar.export'
|
||||
| 'toolbar.exportAria'
|
||||
| 'toolbar.exporting'
|
||||
| 'toolbar.exportTitle'
|
||||
| 'toolbar.search'
|
||||
| 'toolbar.searchPlaceholder'
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** The trajectory view tab label and toolbar strings. */
|
||||
'trajectory': TrajectoryKey
|
||||
}
|
||||
}
|
||||
|
||||
/** Simplified Chinese dictionary (the key-set source of truth). */
|
||||
export const zh: Record<TrajectoryKey, string> = {
|
||||
'view.trajectory': '轨迹',
|
||||
'toolbar.aria': '轨迹工具栏',
|
||||
'toolbar.duration': 'Duration',
|
||||
'toolbar.useActualDuration': 'Use actual duration',
|
||||
'toolbar.useEqualWidth': 'Use equal-width operations',
|
||||
'toolbar.actualTime': '实际时间',
|
||||
'toolbar.turns': 'Turns',
|
||||
'toolbar.expandTurns': 'Expand turns',
|
||||
'toolbar.collapseTurns': 'Collapse turns',
|
||||
'toolbar.calls': 'Calls',
|
||||
'toolbar.expandCalls': 'Expand calls',
|
||||
'toolbar.collapseCalls': 'Collapse calls',
|
||||
'toolbar.export': 'Export',
|
||||
'toolbar.exportAria': 'Export session log',
|
||||
'toolbar.exporting': 'Exporting…',
|
||||
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
|
||||
'toolbar.search': '搜索轨迹',
|
||||
'toolbar.searchPlaceholder': '搜索',
|
||||
}
|
||||
|
||||
/** English dictionary. */
|
||||
export const en: Record<TrajectoryKey, string> = {
|
||||
'view.trajectory': 'Trajectory',
|
||||
'toolbar.aria': 'Trajectory toolbar',
|
||||
'toolbar.duration': 'Duration',
|
||||
'toolbar.useActualDuration': 'Use actual duration',
|
||||
'toolbar.useEqualWidth': 'Use equal-width operations',
|
||||
'toolbar.actualTime': 'Actual time',
|
||||
'toolbar.turns': 'Turns',
|
||||
'toolbar.expandTurns': 'Expand turns',
|
||||
'toolbar.collapseTurns': 'Collapse turns',
|
||||
'toolbar.calls': 'Calls',
|
||||
'toolbar.expandCalls': 'Expand calls',
|
||||
'toolbar.collapseCalls': 'Collapse calls',
|
||||
'toolbar.export': 'Export',
|
||||
'toolbar.exportAria': 'Export session log',
|
||||
'toolbar.exporting': 'Exporting…',
|
||||
'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)',
|
||||
'toolbar.search': 'Search trajectory',
|
||||
'toolbar.searchPlaceholder': 'Search',
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user