diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml new file mode 100644 index 0000000000..c5b051c49c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/archived/feature/2026-08-25-unified-task-list.md +2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd +2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5 diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md new file mode 100644 index 0000000000..f2f955e36a --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.md @@ -0,0 +1,49 @@ +# Agent Note: One session-header task list — jobs joined with activities in the UI projection only + +Status: implemented +Archived: 2026-09-03 + +English | [中文](2026-08-25-unified-task-list.zh.md) + +Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + +## Problem + +The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy. + +## Decision + +The service planes stay orthogonal; the join happens once, per row, in the UI projection. + +- **`ctx.jobs` and `ctx.activities` remain separate services.** Jobs are the control plane: model-visible ids, a consuming `readOutput` cursor the model drains, `stopping`, `kill`, and the `reported` completion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log. +- **`dsh-client-ui-activity` renders the single merged list; `dsh-client-ui-jobs` is deleted** (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. Each `jobsBySession` row is joined with the activity carrying its `correlation.jobId`: the job supplies identity, lifecycle (`stopping` exists only there), duration, and the model-visible `detail`; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list. +- **User-facing vocabulary is "task" (任务)** — `N tasks running` / `N 个任务进行中` — because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stay `activity`: internal names follow the naming ledger, not display copy. +- **Hierarchy**: live rows first (label over a kind-badge-and-status second line, ticking duration), then settled rows compacted to one de-emphasized line; each non-empty section carries its own heading (Running / Finished). Rows without an observable activity render as static rows with no expansion affordance. + +Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer. + +## Alternatives considered + +- **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above. +- **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway. + +## Refinements from browser acceptance + +Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band: + +- **The header band order is preset → tasks → subagent catalog.** The root-session descendant count moved out of the breadcrumb (`header.lineage`) into the actions band (order 30) and dropped its `/` separator — a root session has no hierarchy to breadcrumb; child sessions keep their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns the moved seat; the lineage renderer now renders nothing on roots. +- **The panel's copy control copies the command** (`TerminalBlock.copyText`), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. Supplying `copyText` also keeps the control rendered before any output exists. +- **The panel scrolls instead of folding**: commands and output lines wrap in full (`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`, so the output region never scrolls sideways) and the output region caps at a fixed height (`--dsl-terminal-output-max-height`) with `maxLines: Infinity` disabling the head/tail fold. +- **The panel draws no run-state dot** (`TerminalBlock.runStateDot: false`) — the row above it already carries the same state — and reclaims the dot gutter via `--dsl-terminal-gutter`. The panel's left spine was dropped with it. + +All four land as opt-ins on `TerminalBlock` (props or `--dsl-terminal-*` variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged. + +## Testing + +Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; `pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` runs them. The keyless web e2e pair (`background-job-list`, `live-activity-stream`) replays the merged list end to end against ARIA goldens; `agent-preset-selection` pins the header band order. + +## Consequences + +- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end. +- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note. +- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary. diff --git a/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md new file mode 100644 index 0000000000..5a4c51a6cc --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-25-unified-task-list.zh.md @@ -0,0 +1,49 @@ +# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并 + +Status: implemented +Archived: 2026-09-03 + +[English](2026-08-25-unified-task-list.md) | 中文 + +Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + +## 问题 + +[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。 + +## 决策 + +服务面保持正交;合并只在 UI 投影层逐行发生一次。 + +- **`ctx.jobs` 与 `ctx.activities` 仍是两个服务。** jobs 是控制面:模型可见的 id、模型消耗式的 `readOutput` 游标、`stopping`、`kill` 与 `reported` 完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。 +- **`dsh-client-ui-activity` 渲染唯一的合并列表;`dsh-client-ui-jobs` 删除**(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个 `jobsBySession` 行与携带其 `correlation.jobId` 的 activity 逐行 join:job 提供身份、生命周期(`stopping` 只存在于此)、时长与模型可见 `detail`;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。 +- **用户可见词汇统一为"任务"(task)**——`N 个任务进行中` / `N tasks running`——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持 `activity`:内部名遵循命名台账,不跟随展示文案。 +- **层次**:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),已结束的行折为降权单行;每个非空分组带自己的标题(进行中 / 已结束)。没有可观察 activity 的行渲染为无展开交互的静态行。 + +刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。 + +## 曾考虑的替代方案 + +- **合并服务层**——因上述游标语义与模型可见性理由否决。 +- **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。 + +## 浏览器验收带来的细化 + +在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带: + +- **头部条带顺序为 preset → 任务 → subagent 目录。** 根会话的后代数量从面包屑(`header.lineage`)移入操作区(order 30)并去掉 `/` 分隔符——根会话没有可面包屑的层级;子会话保留 `根 / 子` 切换器,那里的 `/` 是真实层级。移位后的席位由 `SubagentCatalogAction` 拥有;谱系 renderer 在根会话上不再渲染任何东西。 +- **面板的复制控件复制命令**(`TerminalBlock.copyText`)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供 `copyText` 还让控件在任何输出出现之前就保持渲染。 +- **面板以滚动取代折叠**:命令与输出行完整换行(`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`,输出区因此永不横向滚动),输出区以固定高度封顶(`--dsl-terminal-output-max-height`),`maxLines: Infinity` 禁用头尾折叠。 +- **面板不画运行状态点**(`TerminalBlock.runStateDot: false`)——上方的行已携带同一状态——并通过 `--dsl-terminal-gutter` 收回状态点落区。面板左侧竖线随之移除。 + +四项全部以 `TerminalBlock` 的可选项落地(prop 或 `--dsl-terminal-*` 变量),工具卡片保持既有的折叠/省略/状态点行为不变。 + +## 测试 + +组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;`pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` 运行它们。无密钥 web e2e 对(`background-job-list`、`live-activity-stream`)按 ARIA golden 端到端回放合并列表;`agent-preset-selection` 钉住头部条带顺序。 + +## 后果 + +- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。 +- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。 +- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 74d114c6bb..ca509c1070 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -1378,6 +1378,9 @@ "feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml": "sha256:86bd02d56cf95ac465d0f6e326ccf75432ffb9c3db47373ae6a795195cff9ea7", "feature/2026-08-25-loaded-turn-chat-navigation.md": "sha256:0ee1cb6da76b93e467d747aa8ba07697ba648cb213ba52202c51ee1e85dff1a8", "feature/2026-08-25-loaded-turn-chat-navigation.zh.md": "sha256:5d3e567027fc65241856bf9f9cf0d73a20d53214c49bfbc884ab2ddd19503918", + "feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33", + "feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df", + "feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce", "feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml": "sha256:69179f3056c6d5fc35119e83dd7a5af30213502dac3c1f2d4fd43eae96ee2ecf", "feature/2026-08-26-web-trigger-menu-presentation-polish.md": "sha256:c54e3bbe528d6be711541c61e777e6e4ed4fbc1374df97d846b015d3693679f4", "feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md": "sha256:7db49018eebbba539838d734a80fbb330e2b73fea3b343900591a3605c6c03b5", diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index e7aa78354c..22bf3c01c4 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -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 .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md -2026-06-20-generic-long-running-tool-runtime.md: c386d3b338c94e0e5bbf8e99c2c7a30971ba92b7 -2026-06-20-generic-long-running-tool-runtime.zh.md: 002514740a15c5961960d20976fc18f7fd8071ff +2026-06-20-generic-long-running-tool-runtime.md: 5960eb268656d7e316c763631451db1c6d1c6e49 +2026-06-20-generic-long-running-tool-runtime.zh.md: ea63ae82962a735b3a96e1803708172a33f83e9d diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md index c386d3b338..5960eb2686 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md @@ -27,7 +27,7 @@ The literal types live on the [tasks subsystem page](../../../../docs/subsystems The process-local provider also owns bounded admission, whose rationale is recorded in the [bounded background job admission decision](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md). Its positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count. -`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. +`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobView`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families. A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', jobId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the job runtime: later cancellation of the producing tool call must not kill the published task. `job_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`. @@ -62,7 +62,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow - `read(id, caller?)` for a consuming stream delta or an idempotent final result. - `kill(id, caller?, reason?)` for cancellation. - `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting. -- `onJobDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment. +- `events.subscribe(filter, listener)` for effect-scoped observation of registration, progress, output, settlement, and removal, filtered by owner or owner scope, with listener containment. - `attachController(name)` for the task-controller availability fence. `wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing. @@ -81,7 +81,7 @@ Stream reads share one task-scoped consuming cursor because the owning model is The system prompt tells the model to retain job ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion delivers a logged message to the exact owner's session. A busy owner is injected; an idle owner is woken, under the bounded policy the [idle-owner wake decision](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md) owns. -The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job ` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. +`dsh-tool-jobs` keeps the delivery ledger: a wait claims the task when it starts (and withdraws if the task outlives the wait) and a `job_kill` claims it when accepted, so a settlement the model already received through a tool result injects no redundant completion notice; teardown settlements and unowned tasks are never announced ([seam consolidation](2026-09-03-jobs-seam-consolidation.md)). Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a view carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job ` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size. ## Producer opt-in @@ -105,7 +105,7 @@ Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup, ### An immediate abstract task-runtime backend -The current `JobStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics. +The current `JobSpec.run()` contract returns in-process hooks and pushes output through an in-process `JobHandle`, and owners resolve to live `Agent`s in-process. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics. ### Consumer-owned authorization or cleanup events diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index 002514740a..ea63ae8296 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -27,7 +27,7 @@ Status: implemented 进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 -`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 +`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobView`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', jobId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`job_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。 @@ -62,7 +62,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 - `read(id, caller?)`:消费式流增量或幂等的最终结果。 - `kill(id, caller?, reason?)`:取消。 - `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。 -- `onJobDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。 +- `events.subscribe(filter, listener)`:effect 作用域内对注册、进度、输出、结算与移除的观察,按所有者或所有者 scope 过滤,具有监听器隔离。 - `attachController(name)`:任务控制器可用性防线。 `wait` 在任务完成时返回终止快照,在等待超时时返回当前快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内发生结算时,不会代表一个实际未收到任何内容的读取方压制完成通知。 @@ -81,7 +81,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。 -当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 +`dsh-tool-jobs` 持有投递台账:等待在开始时认领任务(任务比等待活得更久则撤回),`job_kill` 被接受时也认领,因此模型已经通过工具结果收到的结算不会再注入冗余的完成通知;teardown 结算与无主任务从不播报([seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md))。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当视图携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 ## 生产方显式启用 @@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ### 立即抽象任务运行时后端 -当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 +当前 `JobSpec.run()` 约定返回进程内钩子并经进程内的 `JobHandle` 推送输出,所有者也在进程内解析为存活的 `Agent`。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 ### 由消费方负责授权或清理事件 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml new file mode 100644 index 0000000000..497481e7d2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md +2026-09-01-jobs-absorb-activity-record.md: 508608f7eb694bf131bad9a25e856d32c7079559 +2026-09-01-jobs-absorb-activity-record.zh.md: 2c157d951042f1cd6f8a3a66129773bc99cd5b02 diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md new file mode 100644 index 0000000000..508608f7eb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md @@ -0,0 +1,48 @@ +# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed + +Status: implemented + +Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring and direct operations on `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds. + +English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md) + +## Problem + +The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only). + +The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split. + +## Decision + +`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed. + +- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops. +- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented. +- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design. +- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it. +- **The wire splits by role.** `SessionJob.record` (present exactly for record jobs) marks a row observable on the session control stream; `job.follow({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply. +- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `record` is present. The two-roster join is deleted. +- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows. + +## Alternatives considered + +- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`. +- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream. +- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family. + +The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched. + +## Review corrections + +Review of the merged design settled five points that the sections above leave implicit: + +- **The pump's wait holds constant resources.** `pumpJobOutput` subscribes to the producer's `done` once and keeps one pending timer; settlement clears the timer and wakes the current wait. Racing the same pending promise every poll round retained a reaction per round until the job ended — a day-long job at the default cadence accumulated over a million closures. +- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR. +- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor. +- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need. +- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped. +- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id. + +## Consequences + +Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `api-job-controller`. diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md new file mode 100644 index 0000000000..2c157d9510 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md @@ -0,0 +1,48 @@ +# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除 + +Status: implemented + +Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环与 `ctx.jobs` 上的直接操作——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。 + +[English](2026-09-01-jobs-absorb-activity-record.md) | 中文 + +## 问题 + +[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。 + +唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。 + +## 决策 + +`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。 + +- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。 +- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。 +- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。 +- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。 +- **wire 按角色拆分。** `SessionJob.record`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.follow({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。 +- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `record` 存在时可展开。双名册 join 被删除。 +- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。 + +## 曾考虑的替代方案 + +- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。 +- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。 +- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。 + +[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。 + +## 评审修正 + +对已合并设计的评审确定了上文各节未言明的五点: + +- **泵的等待资源恒定。** `pumpJobOutput` 对生产者的 `done` 只订阅一次,同一时刻只保留一个待触发的 timer;结算清掉 timer 并唤醒当前等待。此前每个轮询轮次都对同一个 pending promise 赛跑,每轮留下一个 reaction 直到任务结束——默认节奏下跑一天的任务会累积上百万个闭包。 +- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。 +- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。 +- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。 +- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。 +- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。 + +## 后果 + +每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。 diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml new file mode 100644 index 0000000000..1d1a4bc731 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md +2026-09-03-jobs-seam-consolidation.md: 2742ff1a80ec4cc4d8e5948f63d306abb9753733 +2026-09-03-jobs-seam-consolidation.zh.md: 1fa61919a27362ce42419b57e0960e9be86ea07e diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md new file mode 100644 index 0000000000..2742ff1a80 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md @@ -0,0 +1,35 @@ +# Agent Note: One output ring, one projection, one event stream for background jobs + +Status: implemented + +English | [中文](2026-09-03-jobs-seam-consolidation.zh.md) + +## Problem + +The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history. + +## Decision + +- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once. +- **Two cursors, one storage.** `JobRegistry.read(id)` is the stateful model cursor; `JobRegistry.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did. +- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in. +- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`. +- **Direct caller-scoped operations.** `JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` accept the caller session on each call. Omitting the caller permits only unowned jobs. The output ring and browser streams do not require a separate caller-bound operation object. +- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total. +- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement. +- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.list({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.follow`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind. + +`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change. + +## Alternatives considered + +- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection. +- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed. +- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`). +- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`. + +## Consequences + +The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it. + +Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The `@deepseek-ai/dsh-jobs/invariant` companion checks the announced event protocol per job (registered first, one settlement, removal last) and each announced projection against the registry's own read, which is the cross-observation the [companion rule](../simplification/2026-08-28-omit-unneeded-invariant-companions.md) requires; the earlier single-view field checks are gone. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered. diff --git a/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md new file mode 100644 index 0000000000..1fa61919a2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md @@ -0,0 +1,35 @@ +# Agent Note:后台 job 的一个输出环、一个投影、一条事件流 + +Status: implemented + +[English](2026-09-03-jobs-seam-consolidation.md) | 中文 + +## 问题 + +job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。 + +## 决策 + +- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。 +- **两个游标,一份存储。** `JobRegistry.read(id)` 是有状态的模型游标;`JobRegistry.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。 +- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。 +- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。 +- **直接传入调用方的操作。** `JobRegistry.list`、`get`、`read`、`readAt`、`kill` 与 `wait` 在每次调用时接收调用方会话。省略调用方时仅允许访问无主 job。输出环与浏览器流不需要独立的调用方操作对象。 +- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。 +- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。 +- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.follow` 旁拥有 `job.list({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。 + +`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。 + +## 考虑过的替代方案 + +- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。 +- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。 +- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。 +- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。 + +## 后果 + +模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。 + +合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。`@deepseek-ai/dsh-jobs/invariant` 伴生插件校验每个 job 的事件协议(先 `registered`、恰一次结算、最后 `removed`)以及每个通告的投影与注册表自身读取的一致性,这正是[伴生插件规则](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)要求的交叉观察;先前只针对单个视图的字段校验已删除。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml new file mode 100644 index 0000000000..061872368c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md +2026-09-20-sidebar-retained-tab-layout.md: 9579a0ba47c56f1d533f066fddd37e7ddf443346 +2026-09-20-sidebar-retained-tab-layout.zh.md: 365ad5682c8610cab5dc855984e58b4303bfae49 diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md new file mode 100644 index 0000000000..9579a0ba47 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.md @@ -0,0 +1,82 @@ +# Agent Note: Stable Sidebar tab mounting and Session retention + +Status: implemented + +English | [中文](2026-09-20-sidebar-retained-tab-layout.zh.md) + +## Problem + +Restoring Session-specific tab records and URLs does not preserve a live browsing context. Unmounting a body or changing its DOM ancestors during tab, pane or Session transitions disconnects its page. Electron 44's `WebViewElement.disconnectedCallback` detaches and resets its guest, so preserving a React key or caching an element reference cannot compensate for disconnected ancestors. Content size, clipping and overlap must also follow the actual parent layout. + +## Decision + +Sidebar owns live-body retention, independently of Browser navigation and Workspace storage. A retained page's DOM ancestors remain connected and unchanged during Session switches, tab selection, pane moves, collapse, floating and docking back. Browser Presentation attaches directly inside its content container and performs no placeholder measurement or overlay positioning. + +### Layout ownership + +- `DockLayout` renders the Sidebar's one or two horizontal panes with stable tab siblings. The existing recursive `DockSurface` and separate `FloatLayer` remain available for general split-tree rendering; the layout engine and serialized format are unchanged. +- Every tab has a stable Grid cell, frame, Header container and Body container. Explicit Grid columns determine docking placement; Flex assigns Header and Body height. Floating changes that same frame to `position: fixed` without changing its parent or replacing Body. +- A floating frame's zero-sized Grid cell establishes a stacking context at the float layer. CSS `order` expresses float depth within that layer; DOM order stays sorted by tab identity. Raising many floats cannot increment their layer past menus. Selection and strip order never reorder the content-node list. +- Common ancestors have no transform, clipping or enclosing stacking context. Docked cells own collapse transforms and visibility; each frame clips its content. Fullscreen changes the same root's width and docked layer. Existing window-drag region pulses remain, but no RAF copies content positions. +- Split and float gestures retain their existing measurements and operation state. These update user-selected split fractions and float rectangles, not a second Browser rectangle. Browser resize and content sizing use normal CSS layout. + +### Session and tab lifetime + +`SidebarRightTabDefinition.keepMounted` enables lazy retention for a type. Desktop Browser enables it; other types keep visibility-based body mounting. A retained body is first created on display, then survives hiding until its occurrence or provider lifetime ends. The policy is not persisted and does not select a storage partition. + +`SidebarSessionView` acquires and releases one `SessionReference` using the independent `sidebarView` source, and owns its committed-mount count and retained-Body holds. `SidebarSessionViews` manages selection, retention policy and view indexes; it delegates lifetime operations to the view rather than mutating its counts or releasing its reference. `RightbarRoot` receives `SidebarSessionViewSnapshot` values and the existing injected callbacks, not resource-owner objects. It renders each through its own explicit `SessionProvider`, with a stable Session key; the renderer's general Session-remount semantics remain unchanged. + +Tabs contribute Body-retention holds rather than independent Session references. The View's stable `retainTab` callback reaches its seat through owner props, so replacing Session injection bindings does not release and reacquire those holds. Those holds follow the existing occurrence signal, including a new signal after close-and-undo; the view does not create a second tab lifetime. A retired view stays in the collection's reference index until its own disposal removes it, so plugin shutdown can also reach views whose React mounts have not finished. + +Only the selected Session reports frame occupancy or binds public Sidebar navigation. Background tab actions still address their own adopted store. `tab.visible` includes Session and global-panel visibility; floating does not make a background Session visible. A Session change or global panel hides its whole content tree, while Sidebar collapse hides only docked cells and leaves foreground floats visible. Hidden content is inaccessible to pointer and keyboard input, and hidden body focus is blurred. + +A background Session with no initialized retained body leaves the view list. Its reference releases after its committed React root unmounts, avoiding scope disposal underneath a mounted subtree. Plugin disposal releases all references, including retired views awaiting unmount. The Sidebar plugin depends on `sessions` and `uiSession`, so Cordis unloads it when either dependency ends; `ui-session` owns binding invalidation. Views do not register disposal callbacks on the shared Session Context. Catalog absence alone is not treated as deletion. Unvisited Browser tabs and unrelated Sessions are not eagerly opened. + +### Provider responsibilities + +`BrowserFrame` remains navigation and observable page state. Presentation creates and attaches the carrier DOM, without pane, clipping or stacking knowledge. Electron's provider receives physical attach/detach callbacks: hiding retained content emits neither, while an actual unmount cancels pending attachment and releases the guest. A subsequent physical mount recreates it from its known address. Disposal waits for pending creation and guest releases; an acquisition completing after cancellation is released rather than attached. + +The [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) retains guest security, shared IPC declarations and Workspace partition ownership. Separate tabs and Sessions never share one page instance merely because they share a storage partition. Web remains disabled by default and retains its iframe carrier when explicitly enabled. + +### Retention versus recovery + +| Operation | Content handling | +|---|---| +| Switch tabs | Hide the old retained cell; display the target's existing body. | +| Split, reorder or move to another pane | Change logical placement and Grid assignment, without moving Body. | +| Float, dock back or raise | Change the same frame's CSS mode or cell paint order. | +| Collapse Sidebar | Hide docked cells; foreground floats remain visible. | +| Session A → B → A | Hide and reshow A's original tree instead of loading its saved URL. | +| Close, provider unload or actual generation termination | Release the body and guest; undo creates a new occurrence. | +| Application restart or window reload | Show the saved title and URL; load only after an explicit restore or address submission. | +| Guest crash | Retry through the provider; page memory and native history are not durable. | + +[Layout and provider recovery](2026-09-14-sidebar-layout-provider-recovery.md) remains independent: persisted tab identity, placement and address support cold starts, not DOM serialization. A hidden page may continue scripts, media and networking. + +## Alternatives considered + +**Hide panes only.** Collapse works, but moving between panes or into another floating portal still changes content ancestors. + +**Retain bodies only in the foreground Session.** Session-slot remounting disconnects the page and loses its live state on Session switches. + +**Give each tab its own Session reference.** A view's tabs share one `SessionProvider`, whose reference must exist before their bodies render. Per-view ownership covers that parent and its children without an additional reference lifecycle for each tab. + +**Cache and reparent DOM.** Ordinary reparenting invokes Electron disconnection. Atomic-move dependencies, private attachment APIs and lifecycle monkey-patches are not used. + +**Position Browser content in a separate overlay.** Measurement or CSS anchors can align an overlay, but its content remains outside the parent Grid/Flex layout that must own its sizing and clipping. + +**Use native popovers.** Top-layer elements cannot be covered by ordinary Portal content through `z-index`. Migrating menus and dialogs into another stacking system expands the work beyond Sidebar; the implementation preserves the existing Portal and layer model. + +**Raise floats by increasing their root z-index.** An unbounded float count can cross menu and modal layers. Equal-layer Grid cells with CSS paint order retain both stacking and DOM identity. + +**Save the URL and rebuild when hidden.** This cannot preserve forms, script state, scroll position or native history. It is recovery after loss, not live retention. + +## Consequences + +Layout correctness belongs to Sidebar/DockKit rather than Browser. The cost is stable content containers and explicit Session-reference ownership: background retention holds Session scopes and subscriptions as well as page memory. There is no implicit LRU or idle timeout; future reclamation requires an explicit suspension/recovery policy. Other tab types do not automatically acquire these costs. + +Sidebar/DockKit own chrome, float stacking, focus and platform styles as well as retained content placement. The [docking infrastructure](../feature/2026-09-04-right-sidebar-docking-infrastructure.md) owns the layout engine, persistence and tab-type navigation; the [Desktop Browser decision](../feature/2026-09-20-desktop-browser-webview.md) owns guest navigation, storage and security. + +## Verification + +Focused tests cover retained bodies across Session-source registration and removal, persisted-layout validation, and existing Sidebar presentation. Host and Client TypeScript compilation passes. The user manually accepted a rebuilt Electron cold start; no GIF was recorded. Dedicated real-Electron evidence for guest identity, form and history retention, multi-float paint order, resizing, clipping, drop hints, focus and platform window controls remains separate from these unit tests. diff --git a/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md new file mode 100644 index 0000000000..365ad5682c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-20-sidebar-retained-tab-layout.zh.md @@ -0,0 +1,82 @@ +# Agent Note: Sidebar 的稳定 Tab 挂载与会话保活 + +Status: implemented + +[English](2026-09-20-sidebar-retained-tab-layout.md) | 中文 + +## Problem + +恢复各会话的 Tab 记录和 URL, 并不能保留活的浏览上下文. 在 Tab、Pane 或会话切换期间卸载 Body 或改变其 DOM 祖先, 都会断开页面. Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach 并重置 guest, 因此保持 React key 或缓存元素引用不能抵消祖先断开. 内容尺寸、裁剪与重叠也必须遵循真实父节点布局. + +## Decision + +Sidebar 负责活 Body 的保留,不依赖 Browser 导航和 Workspace 存储。保活页面的 DOM 祖先在切会话、选 Tab、跨 Pane 移动、收起、浮动和回停靠时保持连接与身份不变。Browser Presentation 直接挂载到内容容器内,不做占位元素测量或浮层定位。 + +### 布局所有权 + +- `DockLayout` 用稳定的 Tab 兄弟节点渲染 Sidebar 的一个或两个水平 Pane。现有递归 `DockSurface` 和独立 `FloatLayer` 仍可用于通用分割树渲染;布局引擎和序列化格式不变。 +- 每个 Tab 都有稳定的 Grid cell、frame、Header 容器和 Body 容器。显式 Grid 列决定停靠归属,Flex 分配 Header 与 Body 的高度。浮动只把同一个 frame 改为 `position: fixed`,不改父节点或替换 Body。 +- 浮动 frame 的零尺寸 Grid cell 在浮窗层建立层叠上下文。CSS `order` 表达该层内的浮窗深度,DOM 顺序始终按 Tab 身份排序。大量浮窗置顶不会把层级递增到菜单之上;选中项和 Tab 条排序均不重排内容节点列表。 +- 共同祖先不设置 transform、裁剪或包住全部内容的层叠上下文。停靠 cell 负责收起 transform 和显隐,各 frame 裁剪自身内容。Fullscreen 修改同一个根的宽度和停靠层级。窗口拖动区域的既有脉冲仍保留,但没有 RAF 复制内容位置。 +- 分栏与浮窗手势沿用原有测量和操作状态,更新用户选择的分栏比例与浮窗矩形,而非维护第二份 Browser 矩形。窗口缩放和内容尺寸变化由普通 CSS 布局处理。 + +### 会话与 Tab 生命周期 + +`SidebarRightTabDefinition.keepMounted` 为类型启用懒保活。Desktop Browser 启用它,其他类型继续按可见性挂载 Body。保活 Body 在首次显示时创建,随后隐藏不卸载,直到 occurrence 或提供方生命周期结束。该策略不持久化,也不选择存储分区。 + +`SidebarSessionView` 使用独立的 `sidebarView` 来源申请、释放一个 `SessionReference`,并管理自己的已提交挂载计数和 Body 保活持有。`SidebarSessionViews` 管理选中、保留策略和视图索引,将生命周期操作委托给视图,而不修改其计数或释放其引用。`RightbarRoot` 接收 `SidebarSessionViewSnapshot` 和已有注入回调,不接收资源所有者对象;它为各视图分别渲染显式 `SessionProvider`,保持会话 key 稳定,渲染器通用的会话重挂载语义不变。 + +Tab 提供 Body 保活持有,而不独立申请会话引用。View 的稳定 `retainTab` 回调通过 owner props 传给席位,因此替换 Session injection binding 不会释放再重新获取这些持有。这些持有跟随已有 occurrence signal,包括关闭后撤销恢复得到的新 signal;视图不创建第二套 Tab 生命周期。退出视图列表的视图仍留在集合的引用索引中,直到自行释放时移除索引,因此插件关闭时也能清理尚未结束 React 挂载的视图。 + +只有选中的会话向 frame 报告占位、绑定公共 Sidebar 导航。后台 Tab actions 仍指向自己的已接管存储。`tab.visible` 包含会话和全局面板的可见性,浮动不会让后台会话变成可见。切会话或打开全局面板隐藏整个内容树,收起 Sidebar 则只隐藏停靠 cell,前台浮窗仍可见。隐藏内容不可通过指针或键盘访问,隐藏 Body 内的焦点会被移除。 + +后台会话没有已初始化的保活 Body 时,从视图列表移除;等已提交的 React 根卸载后再释放引用,避免仍挂载的子树下方先销毁会话 scope。插件释放会清理全部引用,包括等待卸载的已移除视图。Sidebar 插件依赖 `sessions` 和 `uiSession`,任一依赖结束时由 Cordis 卸载它,绑定失效由 `ui-session` 负责。视图不向共享会话 Context 注册销毁回调。仅在目录列表中缺项不视为删除。未访问的 Browser Tab 和无关会话不会提前打开。 + +### 提供方职责 + +`BrowserFrame` 仍只负责导航与可观察页面状态。Presentation 创建、挂载载体 DOM,不了解 Pane、裁剪或层叠。Electron 提供方接收物理 attach/detach 回调:隐藏保活内容不触发这些回调,真正卸载则取消未完成的挂载并释放 guest;之后再次物理挂载时从已知地址重建。dispose(资源释放)会等待未完成的创建和 guest 释放,取消后才完成的申请只释放、不挂载。 + +[Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)继续负责 guest 安全、共享 IPC 声明和 Workspace 分区所有权。不同 Tab 和会话不能因为共享存储分区就共享一个页面实例。Web 默认仍关闭,显式启用时继续使用 iframe 载体。 + +### 保活与恢复 + +| 操作 | 内容处理 | +|---|---| +| 切换 Tab | 隐藏旧的保活 cell,显示目标已有 Body。 | +| 分栏、排序或移到另一个 Pane | 改逻辑归属和 Grid 分配,不搬移 Body。 | +| 浮动、回停靠或置顶 | 改同一个 frame 的 CSS 模式或 cell 绘制顺序。 | +| 收起 Sidebar | 隐藏停靠 cell,前台浮窗保持可见。 | +| 会话 A → B → A | 隐藏并重新显示 A 的原内容树,而非加载已保存 URL。 | +| 关闭、提供方卸载或真实 generation 结束 | 释放 Body 和 guest;撤销关闭创建新 occurrence。 | +| 应用重启或窗口刷新 | 展示保存的标题和 URL,显式恢复或提交地址后才加载。 | +| guest 崩溃 | 通过提供方重试;页面内存和原生 history 不持久化。 | + +[布局与提供方恢复](2026-09-14-sidebar-layout-provider-recovery.zh.md)仍然独立:持久化的 Tab 身份、位置和地址支持冷启动,而不是序列化 DOM。隐藏页面可能继续执行脚本、播放媒体或联网。 + +## Alternatives considered + +**只隐藏 Pane。** 可以覆盖收起,但跨 Pane 移动或进入独立浮动 Portal 仍会改变内容祖先。 + +**只保留前台会话的 Body.** 会话 slot 重挂载会断开页面, 导致切会话时丢失页面运行状态. + +**每个 Tab 独立持有会话引用。** 同一视图的 Tab 共用一个 `SessionProvider`,它的引用必须先于这些 Body 渲染而存在。按视图持有即可覆盖父级及其子节点,无须再为每个 Tab 管理一份引用生命周期。 + +**缓存并搬移 DOM。** 普通 reparent 会触发 Electron 断开处理;实现不依赖原子搬移、私有挂载 API 或生命周期补丁。 + +**把 Browser 内容放在独立浮层中.** 测量或 CSS 锚点可以对齐浮层, 但内容仍处于负责其尺寸与裁剪的父级 Grid/Flex 布局之外. + +**使用原生 popover。** 普通 Portal 内容无法通过 `z-index` 覆盖 top layer 元素。把菜单、对话框迁移到另一套层叠体系会扩大 Sidebar 之外的改动;实现保留既有 Portal 和层级模型。 + +**通过递增根 z-index 置顶浮窗。** 不设上限的浮窗数量可能越过菜单和模态层。同层 Grid cell 的 CSS 绘制顺序同时保留层叠与 DOM 身份。 + +**隐藏时保存 URL 再重建。** 无法保留表单、脚本状态、滚动位置和原生 history。这是丢失实例后的恢复,不是活页面保留。 + +## Consequences + +布局正确性由 Sidebar/DockKit 负责,而不是 Browser。代价是稳定的内容容器与显式会话引用所有权:后台保活不仅持有页面内存,也持有会话 scope 和订阅。没有隐式 LRU 或空闲超时,未来回收需要明确的暂停与恢复策略;其他 Tab 类型不会自动承担这些成本。 + +Sidebar/DockKit 除了负责保活内容布局, 还负责控件、浮窗层级、焦点和平台样式. [停靠基础设施](../feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md)负责布局引擎、持久化和 Tab 类型导航; [Desktop Browser 决策](../feature/2026-09-20-desktop-browser-webview.zh.md)负责 guest 导航、存储和安全. + +## Verification + +定向测试覆盖 Session 数据源注册与注销期间的正文保活、存档布局校验和既有 Sidebar 呈现。Host 与 Client TypeScript 编译通过。用户已手动验收重新构建后的 Electron 冷启动;未录制 GIF。guest 身份、表单与 history 保留、多浮窗绘制顺序、缩放、裁剪、放置提示、焦点和平台窗口控件仍需独立于这些单元测试的真实 Electron 证据。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 8cf2e53343..ed1df3a4fe 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17 -2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38 +2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469 +2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 4b8396c1c0..049785e9cd 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -2,6 +2,10 @@ Status: implemented +Superseded: the roster this note put on the session control stream (`jobsBySession`, `onJobsChanged`) now streams from the job controller's `job.list` into `ctx.jobs` — see [the jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The display decisions below (one roster, sections, durations, no kill control) still hold. + +Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md). + English | [中文](2026-08-08-web-background-job-display.zh.md) ## Problem @@ -64,7 +68,7 @@ The listener is owner-granular rather than task-granular. The only consumer push `onJobDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-jobs` couples to `reported`; `onJobsChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onJobDone`, and each registration is an effect on the calling fiber. -Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. +Service disposal announces each removal: after the registry has cancelled and awaited its jobs, it drops every record and emits one `removed` event per job, so a subscriber on a longer-lived fiber sees the roster empty out instead of keeping a stale set (a subscriber on the registry's own fiber is already gone by then). ### The Session Controller carrier @@ -87,7 +91,7 @@ Two replacement points keep it honest. Each control-stream generation clears the ### The header action -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. +`@deepseek-ai/dsh-client-ui-jobs` registers one entry in `conversation.session.header.actions`, ordered between the preset label and the subagent catalog (`order: 20` against the catalog's 30). Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. @@ -117,7 +121,7 @@ A running one-shot background subagent therefore appears both there and in the s The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. -Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. +Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) pins the roster stream that replaced the control-stream fan-out: the complete visible set on open, a refresh after each lifecycle commit and none per append, owner removal, and a clean abort; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 03a43b87c4..1dd7ee9215 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -2,6 +2,10 @@ Status: implemented +Superseded:本文放在会话控制流上的名册(`jobsBySession`、`onJobsChanged`)现在从 job 控制器的 `job.list` 流入 `ctx.jobs`——见 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)。下文的展示决策(单一名册、分组、时长、没有 kill 控件)仍然成立。 + +Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。 + [English](2026-08-08-web-background-job-display.md) | 中文 ## 问题 @@ -64,7 +68,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void `onJobDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-jobs` 把这套语义与 `reported` 绑在一起;`onJobsChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onJobDone` 一致,每次注册都是调用方 fiber 上的 effect。 -服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 +服务销毁逐条通告移除:注册表取消并等完自己的 job 之后,丢弃每条记录并对每个 job 发出一个 `removed` 事件,因此注册在更长命 fiber 上的订阅者看到名册逐条清空而不是留着陈旧集合(注册在注册表自身 fiber 上的订阅者此时早已消失)。 ### Session Controller 载体 @@ -87,7 +91,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void ### header 入口 -[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.zh.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 +`@deepseek-ai/dsh-client-ui-jobs` 在 `conversation.session.header.actions` 注册一个条目,排在 preset 标签与 subagent 目录之间(`order: 20`,目录为 30)。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 @@ -117,7 +121,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void [web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 -在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 +在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) 钉住取代控制流扇出的名册流:打开时的完整可见集、每次生命周期提交后刷新一次且追加不刷新、拥有者移除,以及干净的中止;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。 ## 影响 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml index 086b337437..0c51d4b42e 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md -2026-08-11-background-job-completion-wakes-an-idle-owner.md: cb724d95f85900b3c55fdebfdc335e9da6439d29 -2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: 1107fa2c8fdd9d3e4e45031c54fb87f5aac8ea18 +2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf +2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943 diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md index cb724d95f8..b8a10a2150 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md @@ -2,6 +2,8 @@ Status: implemented +Update: the `reported` bit and `onJobDone` this note relies on left the registry with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md): `dsh-tool-jobs` now keeps the delivery ledger (a wait or an accepted `job_kill` claims the task) and skips teardown settlements by their `settled` cause, so the wake-or-inject decision below stands while the mechanism it names is the tool's ledger, not a registry flag. + English | [中文](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md) ## Problem diff --git a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md index 1107fa2c8f..adc0770cd0 100644 --- a/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md @@ -2,6 +2,8 @@ Status: implemented +Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)离开注册表:`dsh-tool-jobs` 现在持有投递台账(等待或被接受的 `job_kill` 认领任务),并按 `settled` 事件的 cause 跳过 teardown 结算,因此下文唤醒或注入的决策仍然成立,只是它提到的机制是工具的台账而非注册表标志。 + [English](2026-08-11-background-job-completion-wakes-an-idle-owner.md) | 中文 ## 问题 diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml new file mode 100644 index 0000000000..548e9f5b36 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md +2026-08-24-activity-observation-seam.md: 21fe08fd21ad95eec7a83f6f29b9b94514358bb9 +2026-08-24-activity-observation-seam.zh.md: ffb6766b3e5d226d324ea2894a6e3f758cf1932a diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md new file mode 100644 index 0000000000..21fe08fd21 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md @@ -0,0 +1,53 @@ +# Agent Note: The activity observation seam (`ctx.activities`) and live output streaming to the Web client + +Status: implemented + +Superseded: the standalone seam described here was folded into `ctx.jobs` as the per-job observation record — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md); the durable-vs-live analysis below still holds. + +English | [中文](2026-08-24-activity-observation-seam.zh.md) + +## Problem + +Every incremental reader in the shell, terminal, and jobs layers is single-consumer and consuming: `ShellProcess.readOutput()`, `TerminalSendOperation.readOutput()`, and `JobRegistry.read()` all destroy the delta they return, because the owning model is the intended reader. The [web background-job display](2026-08-08-web-background-job-display.md) shipped the roster on those terms — its carrier is test-pinned to never call `ctx.jobs.read()`, since a browser read would silently steal bytes the model's `job_output` would never see — and explicitly deferred the output phase to "a separate non-consuming observation API". So a human watching the Web client saw that a background build was running but never a line of its output, and the `workflow/log` / `workflow/phase` events scripts are told to narrate with had no consumer at all. + +The substrate below bash already had the right shape: `SubprocessOutputReader.readFrom(fromByte)` is offset-based and explicitly non-consuming, and `bash-local` narrows it to a single cursor pair at the shell seam. + +## Decision + +A new capability seam, `packages/activity/`, is the non-consuming observation plane. An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The seam is pure observation — it starts nothing, cancels nothing, and is invisible to the model. Model-visible facts stay exactly where they were (tool results, `ctx.jobs` cursors), so the "model-visible ⟺ logged" invariant is untouched and a composition without the seam loses only live observation. + +- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump. +- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown. +- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail). +- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](../../archived/feature/2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame. + +Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes: + +- `ShellProcess` gained optional `observed` — the substrate's non-consuming offset readers re-exposed (bash-local and pwsh-local return `handle.collected`; the e2b subprocess provider satisfies the same contract). `dsh-tool-bash` / `dsh-tool-pwsh` open a correlated activity after `jobs.start()` commits and pump `observed` at `activityPollMs` (default 150 ms). +- `dsh-tool-workflow` mirrors each recorded top-level run as a push-mode `workflow` activity, giving the previously unconsumed `workflow/phase` / `workflow/log` events their consumer. + +### Why not durable session events, the control stream, or the jobs seam + +Peer products converge on the same split (Codex marks every exec-output delta "Transient, non-durable" in its rollout policy; Claude Code persists a file path, never deltas; Kimi's envelope has a first-class `volatile: true`; OpenCode's V2 corrected V1's per-chunk durable part updates to "stream fragments are live-only"). In this repository the durable route is also concretely bad: chunk-run packing is hardcoded to the three `assistant/chunk` shapes so a new output event gets zero compression, each event is one uncoalesced WebSocket frame, and reconnect gap-repair re-reads the window — and the job-display note already rejected it ("spill exists precisely so oversized tool output stays out of the log"). The session control stream's whole-snapshot frames are self-healing for rosters but quadratic for streams. Extending the jobs seam would cover only jobs — not workflow narration, not a future foreground stream — and would re-litigate the settled "producers own their buffers" decision; the activity plane instead recovers the substrate's existing offsets. + +### Two cursor regimes, kept separate + +The model keeps its consuming cursors (`job_output` semantics unchanged, zero prompt or tool changes anywhere in this work); observers get absolute offsets any number of readers can hold. This mirrors the strongest peer pattern (Codex: destructive drain for the model, seq-cursor reads for infrastructure) and is what makes the wire path provably non-stealing. + +## Alternatives considered + +**Durable `activity/output` session events with a conversation node.** Replay for free, but the log-growth math above, plus retention pressure on every follower, killed it; the transcript's durable story remains the producers' tool results. + +**Extending `SessionControlFrame` with output.** One fewer stream, but whole-snapshot semantics resend the accumulated buffer per chunk — O(n²) on exactly the data that grows. + +**A signal frame plus RPC pull.** The subagent-catalog shape; rejected for the split-authority apparatus the job-display note documents, and it fails worst at settlement (running row, dead stream). + +**Observation inside the jobs seam.** One integration point, but only for jobs; foreground runs, terminals, and workflow narration would each need another mechanism, and the registry would grow a second buffer beside the producers' own. + +## Testing + +Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy reads, first-wins settlement, owner cleanup, scoped delivery, listener containment, HMR disposal, Loader composition with row config), the pump (cadence, per-source cursors, lossy `gapBefore`, settlement drain, timer hygiene), the controller (baseline/bucket frames, anchor/output/status ordering, resume, frame splitting, fenced owned reads, abort), the client model and reference-counted streams, and both producer taps (real subprocess output for bash including the never-steals-the-model's-cursor assertion; scripted readers for pwsh; event-driven mirror for workflow, including the throwing-registry containment the optionality promise requires). The keyless web e2e drives a real `run_in_background` bash through the full composition and pins ARIA goldens for the streaming panel while the command still runs and for the settled panel after a registry kill. + +## Consequences + +The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](../../archived/feature/2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart. diff --git a/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md new file mode 100644 index 0000000000..ffb6766b3e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md @@ -0,0 +1,53 @@ +# Agent Note:activity 观察 seam(`ctx.activities`)与 Web 客户端实时输出流 + +状态:implemented + +Superseded:本文描述的独立 seam 已折入 `ctx.jobs`,成为逐 job 的观测 record——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md);下文关于持久化与实时通道的分析仍然成立。 + +[English](2026-08-24-activity-observation-seam.md) | 中文 + +## 问题 + +shell、terminal 与 jobs 各层的增量读取器全都是单读者且消费型的:`ShellProcess.readOutput()`、`TerminalSendOperation.readOutput()` 与 `JobRegistry.read()` 返回即销毁增量,因为预期读者就是持有模型。[Web 后台任务显示](2026-08-08-web-background-job-display.zh.md)在这一前提下交付了 roster——其载体被测试钉死绝不调用 `ctx.jobs.read()`,否则浏览器读取会无声偷走模型 `job_output` 永远看不到的字节——并把输出阶段明确推迟给"一个独立的非消费观察 API"。于是人在 Web 客户端只能看到后台构建"在跑",看不到一行输出;脚本被告知用来叙述进度的 `workflow/log` / `workflow/phase` 事件更是全无消费者。 + +bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(fromByte)` 是按 offset、明确非消费的,`bash-local` 在 shell seam 把它窄化成了单游标对。 + +## 决策 + +新建能力 seam `packages/activity/`,作为非消费观察面。**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。该 seam 是纯观察——不启动、不取消任何工作,对模型不可见。模型可见事实原地不动(工具结果、`ctx.jobs` 游标),"模型可见 ⟺ 已记录"不变量不受影响;不装该 seam 的组合只失去实时观察。 + +- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。 +- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。 +- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。 +- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](../../archived/feature/2026-08-25-unified-task-list.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。 + +生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作: + +- `ShellProcess` 新增可选 `observed`——底座的非消费 offset 读取器复出(bash-local 与 pwsh-local 返回 `handle.collected`;e2b subprocess provider 满足同一契约)。`dsh-tool-bash` / `dsh-tool-pwsh` 在 `jobs.start()` 提交后打开带关联的 activity,并以 `activityPollMs`(默认 150 ms)泵取 `observed`。 +- `dsh-tool-workflow` 把每个被记录的顶层运行镜像为 push 形态的 `workflow` activity,让此前无人消费的 `workflow/phase` / `workflow/log` 事件有了消费者。 + +### 为何不用持久会话事件、control 流或 jobs seam + +同行产品收敛于同一切分(Codex 在 rollout policy 里把所有 exec 输出 delta 标为 "Transient, non-durable";Claude Code 持久化文件路径而非 delta;Kimi 的信封有一等的 `volatile: true`;OpenCode V2 把 V1 逐 chunk 持久化 part 更新纠正为 "stream fragments are live-only")。在本仓库持久路线还有具体的坏处:chunk-run 打包只认三种 `assistant/chunk` 形态,新输出事件零压缩;每事件一条未合并的 WebSocket 帧;重连缺口修复要重读窗口——且 job 显示 note 已否决过它("spill 的存在就是为了让超大工具输出不进日志")。session control 流的整值帧对 roster 自愈、对流则是二次方开销。扩 jobs seam 只覆盖 jobs——workflow 叙述与未来的前台流都覆盖不到——还要重翻已定案的"生产者拥有自己的缓冲";activity 面转而复用底座既有的 offset。 + +### 两套游标制,各归其位 + +模型保留消费游标(`job_output` 语义不变,本次工作零 prompt/工具改动);观察者拿任意多读者可共持的绝对 offset。这正是最强的同行范式(Codex:模型用破坏性 drain,基础设施用 seq 游标读取),也是 wire 路径可证明"不偷字节"的原因。 + +## 曾考虑的替代方案 + +**持久 `activity/output` 会话事件 + conversation node。** 回放免费,但上述日志膨胀账加上对每个 follower 的保留压力否掉了它;transcript 侧的持久故事仍是生产者的工具结果。 + +**给 `SessionControlFrame` 加输出。** 少一条流,但整值语义会按 chunk 重发累计缓冲——恰好在会增长的数据上 O(n²)。 + +**信号帧 + RPC 拉取。** subagent catalog 的形态;因 job 显示 note 记录的权威割裂机制而否决,且它在结算时刻失效得最难看(行还在跑、流已死)。 + +**在 jobs seam 内做观察。** 一个集成点,但只对 jobs;前台运行、terminal 与 workflow 叙述各需另一套机制,注册表还得在生产者自己的缓冲旁再养一份。 + +## 测试 + +单测钉住注册表(offset、淘汰、UTF-8 安全裁尾、lossy 读取、first-wins 结算、owner 清理、scope 投递、监听容错、HMR 可逆、带行配置的 Loader 组合)、泵(节奏、每源游标、lossy `gapBefore`、结算排干、定时器卫生)、controller(baseline/桶帧、锚点/输出/状态排序、续传、帧切分、有栅栏的有主读取、abort)、客户端模型与引用计数流,以及两类生产者接入(bash 用真实子进程输出,含"绝不偷模型游标"断言;pwsh 用脚本化读取器;workflow 用事件驱动镜像,含可选性承诺要求的 throwing-registry 容错)。无钥 web e2e 经完整组合驱动真实 `run_in_background` bash,钉住命令仍在运行时流式面板与注册表 kill 后已结算面板的 ARIA golden。 + +## 后果 + +Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](../../archived/feature/2026-08-25-unified-task-list.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml index 5e501e5f46..137c5eba63 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md -2026-09-04-right-sidebar-docking-infrastructure.md: 3ebfe4b3a95ab5820682098be2a4c43f172d1a02 -2026-09-04-right-sidebar-docking-infrastructure.zh.md: dc6516324a368dd86b3e01afb4051a4b20697363 +2026-09-04-right-sidebar-docking-infrastructure.md: c5bfb75730f2037826723bc7fd0aa2bb01005d1e +2026-09-04-right-sidebar-docking-infrastructure.zh.md: 4959032b53f84654205a1973aee781acb356f999 diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md index 3ebfe4b3a9..c5bfb75730 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.md @@ -16,6 +16,8 @@ The right column is a per-session docking surface — split panes, tabs, floatin ### Package topology +The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns one retained tab-content tree for docking, floating and Session switches. The engine, layout state and gesture ownership in this note remain applicable. + | Package | Kind | Owns | |---|---|---| | `packages/client/ui-dockkit` | static-linked library, zero DSH dependencies | the layout engine and the React components that render and drive it; consumers compile its sources, and it keeps exactly one stylesheet because a consumer de-duplicates injected sheets by file name | @@ -33,7 +35,7 @@ Components render a snapshot and report settled intents, one per gesture: a drag [Responsive Sidebar and tab information](../architecture/2026-09-07-sidebar-responsive-tab-info.md) supersedes this note's no-concession layout, overlay presentation and product pane limit. `ui-layout` still owns three-column geometry and pixel width preferences; the Sidebar occupant reports presentation through `ctx.layout.openRightbar(track, fullscreen)` and `closeRightbar()`, without the frame injecting the Sidebar package. Exact width rules belong to [ui-layout](../../../../packages/client/ui-layout/README.md). -The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves tab state, and fullscreen covers the viewport while retaining underlying column reservation. Floats still use viewport coordinates through a portal and remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults. +The right Sidebar uses one mounted content tree in normal and fullscreen modes; hiding preserves retained tab state, and fullscreen covers the viewport while retaining underlying column reservation. `DockLayout` renders stable Grid cells with fixed-position floating frames in the same tree; foreground floats remain open when the Sidebar closes. The product limits docking to two horizontal panes and a 20–80% divider; the generic engine keeps its own defaults. ### State @@ -86,7 +88,7 @@ The surface renders tabs whose bodies it does not know: each tab carries a `kind - The Detail panel and its duplicate card presentation are gone (a net removal of roughly 1,400 lines); cards are read in place, and `inspect` opens the trajectory view. - The frame has no centre floor: a viewport narrower than the two edge columns squeezes the conversation toward zero instead of closing a column. - The kit is compiled by its consumers, so a kit change requires a shell rebuild and a page reload; there is no HMR for it. -- The panel, the float host, and the portalled tab menu use hard-coded z-index values; the client still has no z-index token layer. +- Docked cells, floating cells and the portalled tab menu use fixed stacking levels; floating cells use CSS paint order within their level. The client still has no global z-index token layer. ## Testing diff --git a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md index dc6516324a..4959032b53 100644 --- a/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md +++ b/.agents/notes/implemented/feature/2026-09-04-right-sidebar-docking-infrastructure.zh.md @@ -16,6 +16,8 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 ### 包拓扑 +[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责停靠、浮动与会话切换共用的一棵保活 Tab 内容树。本文的引擎、布局状态与手势所有权仍然适用。 + | 包 | 形态 | 所有物 | |---|---|---| | `packages/client/ui-dockkit` | 静态链接库,零 DSH 依赖 | 布局引擎与渲染/驱动它的 React 组件;消费方编译其源码,且它只保留一张样式表,因为消费方按文件名去重注入的样式表 | @@ -33,7 +35,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 [响应式 Sidebar 与标签信息](../architecture/2026-09-07-sidebar-responsive-tab-info.zh.md)取代本记录中的无让步布局、覆盖模式与产品窗格上限。`ui-layout` 仍拥有三列几何与像素宽度偏好,Sidebar 占位项通过 `ctx.layout.openRightbar(track, fullscreen)` 和 `closeRightbar()` 报告呈现方式,框架不注入 Sidebar 包。具体宽度规则见 [ui-layout](../../../../packages/client/ui-layout/README.zh.md)。 -右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留标签状态,全屏覆盖视口并保留底层列占位。浮窗仍经 portal 使用视口坐标,不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。 +右栏在普通与全屏模式下使用同一棵已挂载内容树;隐藏保留已保活的标签状态,全屏覆盖视口并保留底层列占位。`DockLayout` 在同一棵树中渲染稳定 Grid cell 与 fixed 定位的浮动 frame,前台浮窗不随右栏关闭。产品限制为两个水平窗格与 20–80% 分割比例,通用引擎保留自己的默认值。 ### 状态 @@ -86,7 +88,7 @@ Agent 产出的文件是最尖锐的案例。产出文件 chip 或 `read` 行的 - Detail 面板及其重复的卡片展示消失(净删约 1,400 行);卡片就地阅读,`inspect` 打开 trajectory 视图。 - 框架没有中列下限:视口窄于两侧列之和时会话区被挤向零,而不是关掉某一列。 - 库由消费方编译,改库须重建壳并刷新页面;它没有 HMR。 -- 面板、浮层宿主与 portal 出去的 tab 菜单使用硬编码 z-index;客户端仍没有 z-index token 层。 +- 停靠 cell、浮动 cell 和 portal 菜单使用固定层级,浮动 cell 在所属层内通过 CSS 绘制顺序排序;客户端仍没有全局 z-index token 层。 ## Testing diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml index b261e98e5b..fac9746b09 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.i18n.yaml @@ -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 .agents/notes/implemented/feature/2026-09-16-sidebar-browser.md -2026-09-16-sidebar-browser.md: 3fa4e1dbb70dee97dbe6052d925271d093f2877c -2026-09-16-sidebar-browser.zh.md: 69d78dbf2b959d660c9397c7bf7634547a109a9e +2026-09-16-sidebar-browser.md: eff143cc042da7c6455152f684ec83dfa654a1ec +2026-09-16-sidebar-browser.zh.md: a037c2fb54be745a3a85f43561c0bd587873c008 diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md index 3fa4e1dbb7..eff143cc04 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.md @@ -18,17 +18,21 @@ A parent page cannot inspect or drive a cross-origin iframe's internal history. The address parser accepts `http:` and `https:`, including loopback targets; a host name without a scheme becomes HTTPS. It rejects embedded credentials, the application's own origin, malformed addresses, `file:` URLs, and every other scheme. Document Preview remains the local-file surface. -The current carrier is an iframe in both Web and Desktop. Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy. +The Web carrier is an iframe; Desktop uses the [retained webview carrier](2026-09-20-desktop-browser-webview.md). Its default Web policy is `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`; the frame has no direct download or top-navigation flag. Popups escape the sandbox, and a Web popup retains its opener and can use that chain to navigate the top-level application. Same-origin lets the visited origin use its own cookies and Web storage; it does not make a cross-origin target same-origin with DSH. The iframe sends no referrer and adds no package-owned Permissions Policy, so browser defaults and user grants apply. A rightmost toolbar toggle removes the sandbox attribute for that tab occurrence; the mode is not persisted and renders a warning while active. An unsandboxed page can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. The package performs no Host-side URL probe or proxy. -Each tab receives one `BrowserController` class. Its command interface contains only `loadUrl`, `goBack`, `goForward`, and `reload`; it owns address validation and the `BrowserNavigation` state machine. The `BrowserFrame` interface owns transient sandbox and document state plus carrier operations, and `IframeImpl` implements it for the current iframe carrier. Slot injection exposes keyed frame state through `useBrowserFrame` and supplies plain callbacks, so the React body receives neither the controller nor an observable source; it owns only the editable draft and iframe DOM. A future `ElectronWebViewImpl` can implement the same interface without putting URL or carrier state in the component. +Each tab receives one `BrowserController` for address validation and carrier-independent commands. `BrowserFrame` exposes navigation, observable address/loading/history/error state, and an optional sandbox control; `BrowserPresentation` owns DOM attachment. `pages.ts` composes these two objects into a `BrowserPage`. `IframeImpl` owns application-known `BrowserNavigation`, while `ElectronWebViewImpl` observes native history. Slot injection exposes keyed controller state through `useBrowserState` and plain callbacks; the React body owns its draft and content container, with no carrier branch, controller instance or observable source. -`BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. A remounted body reloads the latest application-known URL and consults its optional initial URL only before the first controlled target. The Session-scoped store only persists immutable snapshots from that class for title rendering and application reload. An occurrence abort removes its bucket; `TabDomain` uses that same abort for both tab removal and `ui-sidebar-right` unload, so unloading or hot-reloading the Sidebar clears Browser history even when DockKit later restores the tab record. +For the iframe carrier, `BrowserNavigation` keeps the canonical current URL, controlled-load revision, navigation state, and a bounded sequence with its index. A new address drops the forward branch; Back and Forward move the index; Reload recreates the last application-known URL without adding history. Within a live controller, remounting a body reloads its last requested URL. The Session-scoped store persists immutable checkpoints for tab titles and cold-start recovery; it does not serialize a live page. + +A controller created from a saved checkpoint offers its last title and URL with a Restore page button. The provider starts idle: mounting the restored tab neither creates a browsing guest nor requests the saved site. Restore, toolbar Reload or address submission starts navigation; a fresh typed-open URL remains an explicit navigation request and loads directly. Before a page is requested, Back and Forward remain disabled, while Reload restores the saved address through the same controller action as Restore. Changing an idle iframe's sandbox does not restore it. This offer belongs to `BrowserControllerState`, not the live `BrowserFrame` interface or the persisted navigation status. + +Occurrence cancellation releases the controller. It removes the saved checkpoint only when the Sidebar's authoritative `openTabs` inventory no longer contains that tab. The inventory publishes layout removal before `TabDomain` aborts its occurrence; plugin unload also aborts occurrences but leaves saved tab membership intact. Cleanup therefore does not interpret plugin unload as a request to erase recovery data. Browser state is presentation state. It does not enter the Session log, model request, resource model, or DockKit layout operations. The existing [right Sidebar infrastructure](2026-09-04-right-sidebar-docking-infrastructure.md), [tab type contract](../architecture/2026-09-05-sidebar-tab-types-and-navigation.md), [resource model](../architecture/2026-09-05-client-resource-model.md), and [Document Preview operations](../architecture/2026-09-08-document-preview-operations.md) retain their existing responsibilities. ## Web navigation states -The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. +The Web carrier treats only the first iframe `load` for a controlled revision as confirmation of the application-known URL. A later `load` for that revision proves that the document changed but cannot reveal the new cross-origin URL. Events carrying an older revision are ignored. These states describe active navigation; an unrequested saved checkpoint remains a separate restore offer. | State | Entry | Address and controls | |---|---|---| @@ -37,22 +41,16 @@ The Web carrier treats only the first iframe `load` for a controlled revision as | `known` | The first iframe `load` arrives for the current revision. | The requested URL remains authoritative even when that first load includes an HTTP redirect. The controls follow the same known-target rules as `loading`. | | `unknown` | A second or later iframe `load` arrives for the current revision. | The last controlled URL is muted and marked `URL changed`. Back and Forward are disabled because iframe exposes no cross-origin `canGoBack` or `canGoForward`; external-open is disabled. Reload starts a new revision at the last controlled URL. | -Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. Changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable. +Address editing is available in every state. An invalid draft reports an address failure without changing the current navigation state. Focusing the unknown address hides its marker and reveals Go; Enter and Go both start a controlled load. For an already requested page, changing sandbox mode reloads the last controlled Web target under a new revision. An iframe `error` event marks only the current `BrowserFrame` revision with a transient load-failure notice; it does not change URL history, and the next controlled document clears it. Browsers do not reliably emit this event for DNS, TLS, mixed-content, CSP, or `X-Frame-Options` failures. `pushState`, `replaceState`, and fragment changes that emit no iframe `load` remain unobservable. -## Deferred Electron carrier +## Electron carrier -Electron `` support is designed but is not registered or tested. The controller keeps the same four commands, while a separate per-tab view object owns attachment, detachment, state observation, and target identity. This follows Playwright's Android WebView split: `AndroidWebView` is a target and lifecycle handle identified by package, process, and debugging socket, while `page()` returns the regular `Page` that owns Web navigation and DOM commands; device-level input remains on `AndroidDevice`. - -The Desktop design enables `webviewTag` only on the application window. Its isolated preload receives an unguessable per-window capability, and every Browser tab appends a fresh UUID to form a distinct non-persistent partition. The main process accepts only an initial `about:blank` guest carrying that capability, removes any preload, and forces sandbox, context isolation, disabled Node integration in all frames, Web security, secure-content checks, disabled nested webviews, and disabled plugins. - -The main process allows page-initiated main-frame navigation and redirects only to credential-free HTTP(S). Requests may use HTTP(S), WebSocket, data, and Blob URLs; direct file, custom-protocol, extension, and privileged requests are cancelled. Permission checks and requests, display capture, device grants, downloads, popup windows, and drag-and-drop navigation are denied. - -The view object keeps an inactive guest connected in an owned hidden DOM host and moves it back into the visible placeholder without recreation. This retains page and target identity across Sidebar body remounts. Because `` participates in renderer layout and compositing, ordinary DOM dialogs, menus, tooltips, and drag previews can cover it. `WebContentsView` remains unsuitable because it is a native child surface: CSS cannot cover it, and every overlay or animation would require main-process visibility and bounds synchronization. - -Each guest is a distinct WebContents and CDP target. Development may expose Electron's process-wide remote-debugging port and select the guest target explicitly. Production keeps that endpoint disabled; browser-use or computer-use requires an authenticated broker that binds one authorized tab to its WebContents and uses `webContents.debugger` or an equivalently scoped transport without publishing every application target. +The [Desktop webview decision](2026-09-20-desktop-browser-webview.md) owns native page lifetime, Workspace storage sharing, guest policy, and runtime verification gaps. The iframe behavior in this note remains independently useful; its cross-origin limitations do not describe the Desktop carrier. ## Alternatives considered +**Automatically reload saved pages when their tabs mount.** This resumes third-party requests and scripts merely by restoring the layout. An explicit restore action preserves the saved address without opening the site. + **Add a Host embeddability probe and persist the sandbox preference.** Rejected because fetching arbitrary targets on the Host adds an SSRF path, the probe can disagree with later redirects, and a persistent global escape makes later tabs inherit an unsafe choice. Browser instead offers an explicit per-tab, non-persistent sandbox toggle with a visible warning. **Treat parent-owned history as the complete Web model.** Rejected because it silently leaves a stale address and enabled actions after an in-frame navigation. The bounded parent-owned history remains useful while the current controlled URL is known; the explicit `unknown` state removes claims that the iframe API cannot support. @@ -67,8 +65,10 @@ Each guest is a distinct WebContents and CDP target. Development may expose Elec Unit tests cover protocol parsing, delegated Markdown links, controller commands and lifecycle, deterministic navigation-state transitions, bounded history, best-effort iframe errors, and plugin disposal. Keyless Web scenarios boot the shipped composition and exercise message-link routing, HTTP(S), Back, Forward, sandbox control, unknown navigation, and protocol refusal. +Manual cold-start restoration and checkpoint retention across plugin unload have no runtime verification yet. + ## Consequences -The Browser adds no Electron privilege and behaves identically in current Web and Desktop builds. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. The deferred Electron carrier requires real packaged-app verification before it can become current behavior. +The iframe carrier exposes no Electron or Node APIs. Many sites refuse iframe embedding or require downloads or top-level navigation withheld from the frame by the default sandbox. An HTTPS application can block public HTTP pages as mixed content or restrict private-network requests, and disabling the sandbox does not bypass those browser policies. Disabling the sandbox otherwise trades its protections for compatibility: the frame can navigate the top-level application under browser activation rules and use downloads, modal dialogs, and input locks. A Web popup that escapes the sandbox retains its opener and can navigate the top-level application through that chain. Neither path adds Electron or Node APIs. The URL gate cannot prevent an embedded page from choosing its own destination. A later iframe load exposes that navigation occurred but not its cross-origin URL; History API and fragment changes can remain completely invisible. -Site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage. +Web site-cookie behavior follows the user's browser and is not isolated per Browser tab. Local files are rejected and remain owned by Document Preview. Persisted URLs can contain sensitive query or fragment values, so users must not enter credentials they do not want retained in application-local browser storage. diff --git a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md index 69d78dbf2b..a037c2fb54 100644 --- a/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md +++ b/.agents/notes/implemented/feature/2026-09-16-sidebar-browser.zh.md @@ -18,17 +18,21 @@ Status: implemented 地址解析器接受 `http:` 与 `https:`,包括 loopback 目标;不带 scheme 的主机名补为 HTTPS。它拒绝内嵌凭据、应用自身 origin、畸形地址、`file:` URL,以及所有其他 scheme。本地文件继续由 Document Preview 负责。 -当前 Web 与 Desktop 都使用 iframe 载体。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。 +Web 使用 iframe 载体;Desktop 使用[保持页面实例的 webview 载体](2026-09-20-desktop-browser-webview.zh.md)。它的默认 Web 策略是 `sandbox="allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox"`;frame 没有直接的下载或顶层导航 flag。popup 会脱离 sandbox,Web popup 会保留 opener,并可以通过该链导航顶层应用。same-origin 允许被访问的 origin 使用自己的 Cookie 与 Web storage;它不会让跨域目标与 DSH 变成同源。iframe 不发送 referrer,也不添加包自有的 Permissions Policy,因此浏览器默认策略与用户授权生效。最右侧 toolbar 开关会为当前 tab occurrence 移除 sandbox attribute;该模式不持久化,启用期间持续显示警告。未受 sandbox 约束的页面可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。本包不执行 Host 侧 URL probe 或代理。 -每个 tab 获得一个 `BrowserController` class。它的命令接口只有 `loadUrl`、`goBack`、`goForward` 与 `reload`;它负责地址校验和 `BrowserNavigation` 状态机。`BrowserFrame` 接口负责临时 sandbox 与 document 状态以及载体操作,`IframeImpl` 为当前 iframe 载体实现该接口。Slot injection 通过 `useBrowserFrame` 提供按 key 索引的 frame 状态,并提供普通 callback,因此 React body 不接收 controller 或 observable source;它只负责可编辑草稿与 iframe DOM。未来的 `ElectronWebViewImpl` 可以实现相同接口,而不把 URL 或载体状态放进组件。 +每个 tab 获得一个 `BrowserController`,负责地址校验与载体无关的命令。`BrowserFrame` 提供导航、可观察的地址/加载/history/错误状态和可选 sandbox 控制,`BrowserPresentation` 负责 DOM 挂载。`pages.ts` 把两个对象组装成 `BrowserPage`。`IframeImpl` 持有应用已知的 `BrowserNavigation`,`ElectronWebViewImpl` 则观察原生 history。Slot injection 通过 `useBrowserState` 提供按 key 索引的控制器状态和普通回调;React body 只持有草稿与内容容器,没有载体分支、控制器实例或可观察源。 -`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。body 重挂载时会重新加载应用最后已知的 URL,并且仅在尚无受控目标时使用可选初始 URL。Session-scoped store 只持久化该 class 的 immutable snapshot,供标题渲染与应用刷新恢复。occurrence abort 会删除其 bucket;`TabDomain` 对 tab 删除与 `ui-sidebar-right` 卸载使用同一个 abort,因此卸载或热重载 Sidebar 会清空 Browser history,即使 DockKit 随后恢复 tab record。 +对于 iframe 载体,`BrowserNavigation` 保存 canonical 当前 URL、受控加载 revision、导航状态,以及有上限的序列与当前位置。新地址丢弃 forward 分支;后退和前进移动 index;刷新重建应用最后已知的 URL 且不增加 history。在仍存活的控制器内,body 重挂载会重新加载最后请求的 URL。Session-scoped store 持久化不可变检查点,供 Tab 标题与冷启动恢复使用,不序列化活页面。 + +从已保存检查点创建的控制器展示上次标题、URL 和“恢复页面”按钮。提供方初始为空闲状态:挂载恢复的 Tab 既不创建浏览 guest,也不请求保存的站点。恢复、工具栏刷新或提交地址才启动导航;新打开 Tab 时显式传入的 URL 仍是主动导航请求,会直接加载。页面尚未请求时,后退和前进仍禁用,刷新则通过与“恢复页面”相同的控制器操作加载保存的地址。切换空闲 iframe 的沙箱也不会恢复页面。恢复提示属于 `BrowserControllerState`,不进入活页面的 `BrowserFrame` 接口或持久化导航状态。 + +occurrence 取消会释放控制器。只有 Sidebar 的权威 `openTabs` 清单已不再包含该 Tab 时,才删除保存的检查点。清单先发布布局中的 Tab 移除,再由 `TabDomain` abort 对应 occurrence;插件卸载也会 abort occurrence,但仍保留已保存的 Tab 成员信息。因此清理不会把插件卸载当成删除恢复数据的请求。 Browser 状态只属于呈现层,不进入 Session log、模型请求、resource model 或 DockKit layout operation。现有的[右侧 Sidebar 基础设施](2026-09-04-right-sidebar-docking-infrastructure.zh.md)、[tab 类型契约](../architecture/2026-09-05-sidebar-tab-types-and-navigation.zh.md)、[resource model](../architecture/2026-09-05-client-resource-model.zh.md)和[文档预览操作](../architecture/2026-09-08-document-preview-operations.zh.md)继续负责各自现有职责。 ## Web 导航状态 -对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。 +对于一个受控 revision,Web 载体只把 iframe 的第一次 `load` 当作应用已知 URL 的确认。同一 revision 后续发生 `load`,只能证明文档已经变化,无法给出新的跨域 URL。携带旧 revision 的事件会被忽略。这些状态描述活动导航;尚未请求的已保存检查点仍是独立的恢复提示。 | 状态 | 进入条件 | 地址与控件 | |---|---|---| @@ -37,22 +41,16 @@ Browser 状态只属于呈现层,不进入 Session log、模型请求、resour | `known` | 当前 revision 收到 iframe 的第一次 `load`。 | 即使第一次加载包含 HTTP redirect,请求 URL 仍是权威地址。控件规则与 `loading` 的已知目标规则相同。 | | `unknown` | 当前 revision 收到 iframe 的第二次或后续 `load`。 | 最后一个受控 URL 变灰,并标记 `URL 已变化`。iframe 不提供跨域 `canGoBack` 或 `canGoForward`,因此后退和前进禁用;外部打开禁用。刷新以最后一个受控 URL 启动新的 revision。 | -每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。 +每个状态都允许编辑地址。无效草稿只报告地址错误,不改变当前导航状态。聚焦 unknown 地址会隐藏标记并显示前往按钮;回车与前往都会启动受控加载。对于已经请求的页面,切换 sandbox 模式会使用新的 revision 重新加载最后一个受控 Web 目标。iframe `error` event 只会为当前 `BrowserFrame` revision 标记临时加载失败 notice;它不改变 URL history,下一个受控 document 会清除它。浏览器不会为 DNS、TLS、mixed-content、CSP 或 `X-Frame-Options` 失败可靠触发该 event。不产生 iframe `load` 的 `pushState`、`replaceState` 与 fragment 变化仍不可观察。 -## Deferred Electron carrier +## Electron carrier -Electron `` 支持已经完成设计,但当前不注册也不测试。controller 保持相同的四个命令,独立的 per-tab view 对象负责 attachment、detachment、状态观察与 target identity。这沿用 Playwright 的 Android WebView 划分:`AndroidWebView` 是由 package、process 与调试 socket 标识的 target 和生命周期 handle,`page()` 返回负责 Web 导航与 DOM 命令的普通 `Page`;设备级输入仍属于 `AndroidDevice`。 - -Desktop 设计只在应用窗口启用 `webviewTag`。其隔离 preload 获得不可猜的 per-window capability,每个 Browser tab 再追加新的 UUID,形成独立非持久化 partition。主进程只接受携带该 capability 且初始地址为 `about:blank` 的 guest,删除任何 preload,并强制启用 sandbox 与 context isolation、在所有 frame 禁用 Node integration、启用 Web security 与安全内容检查、禁用嵌套 webview 和 plugin。 - -主进程只允许页面发起的 main-frame 导航与重定向前往不含凭据的 HTTP(S)。请求可以使用 HTTP(S)、WebSocket、data 与 Blob URL;直接 file、自定义协议、extension 与特权请求都会被取消。permission 检查与请求、显示捕获、设备授权、下载、弹窗和拖放导航全部拒绝。 - -view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中,再把它移回可见占位符而不重建。这会在 Sidebar body remount 后保留页面与 target identity。因为 `` 参与 renderer 布局与合成,普通 DOM dialog、menu、tooltip 与拖拽预览可以覆盖它。`WebContentsView` 仍不合适,因为它是原生 child surface:CSS 无法覆盖它,每个 overlay 或动画都需要主进程同步 visibility 与 bounds。 - -每个 guest 都是独立 WebContents 和 CDP target。开发环境可以开放 Electron 的进程级 remote-debugging port,并明确选择 guest target。生产环境保持该 endpoint 关闭;browser-use 或 computer-use 需要一个经过认证的 broker,把一个已授权 tab 绑定到其 WebContents,并使用 `webContents.debugger` 或等价的 scoped transport,而不公开全部应用 target。 +[Desktop webview 决策](2026-09-20-desktop-browser-webview.zh.md)负责原生页面生命周期、Workspace 存储共享、guest 策略与运行时验证缺口。本文的 iframe 行为仍具有独立价值;其跨域限制不描述 Desktop 载体。 ## Alternatives considered +**Tab 挂载时自动重新加载已保存页面。** 这会仅因恢复布局就恢复第三方请求与脚本执行。显式恢复操作保留已保存地址,但不会自行打开站点。 + **增加 Host embeddability probe 并持久化 sandbox 偏好。** 不采用,因为由 Host 请求任意目标会新增 SSRF 路径,probe 可能与后续重定向结果不一致,而持久化的全局逃生开关会让后续 tab 继承不安全选择。Browser 改为提供显式、按 tab、非持久化的 sandbox 开关,并持续显示警告。 **把 parent-owned history 当成完整 Web 模型。** 不采用,因为 iframe 内导航后,地址会悄然陈旧,而相关操作仍显示可用。当前受控 URL 已知时,有上限的 parent history 仍然有用;显式 `unknown` 状态会移除 iframe API 无法支持的能力声明。 @@ -67,8 +65,10 @@ view 对象把非活动 guest 保持连接并停放在自有隐藏 DOM host 中 单元测试覆盖协议解析、Markdown 链接委托、controller 命令与生命周期、确定性导航状态转换、有界 history、best-effort iframe error 和插件 disposal。Keyless Web 场景启动随附 composition,并覆盖消息链接路由、HTTP(S)、后退、前进、sandbox 控制、unknown 导航和协议拒绝。 +手动冷启动恢复和跨插件卸载保留检查点尚无运行时验证。 + ## Consequences -Browser 不增加 Electron 权限,并在当前 Web 与 Desktop 构建中保持相同行为。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。延期的 Electron 载体必须通过真实打包应用验证,才能成为当前行为。 +iframe 载体不暴露 Electron 或 Node API。很多站点拒绝 iframe 嵌入,或者依赖默认 sandbox 不向 frame 提供的下载或顶层导航。HTTPS 应用可能按 mixed-content 策略阻止公共 HTTP 页面,或限制 private-network 请求;关闭 sandbox 也无法绕过这些浏览器策略。关闭 sandbox 在其他方面会用自身保护换取兼容性:frame 可以按浏览器 activation 规则导航顶层应用,并使用下载、模态对话框和输入锁定。逃逸出 sandbox 的 Web popup 会保留 opener,并可以通过该链导航顶层应用。这两条路径都不会增加 Electron 或 Node API。URL 检查无法阻止 iframe 内页面自行选择目标。后续 iframe load 能表明已经发生导航,但无法给出跨域 URL;History API 与 fragment 变化可能完全不可见。 -站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。 +Web 站点 Cookie 行为遵循用户浏览器,并不按 Browser tab 隔离。本地文件会被拒绝,并继续由 Document Preview 负责。持久化 URL 可能含敏感 query 或 fragment,因此用户不应在地址栏输入不希望保留在应用本地浏览器存储中的凭据。 diff --git a/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.i18n.yaml b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.i18n.yaml new file mode 100644 index 0000000000..254189ee9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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 .agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md +2026-09-20-desktop-browser-webview.md: e69114a90f50d51542e9b6a0d40daef05a93001f +2026-09-20-desktop-browser-webview.zh.md: b0ab8b1a7058ae9cbb1258977cb4c4e2d8c40614 diff --git a/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md new file mode 100644 index 0000000000..e69114a90f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.md @@ -0,0 +1,59 @@ +# Agent Note: Retained Desktop browser webviews + +Status: implemented + +English | [中文](2026-09-20-desktop-browser-webview.zh.md) + +## Problem + +An iframe cannot expose cross-origin navigation or render sites that refuse embedding. An Electron guest loses forms, scroll position and native history if its tab body unmounts on selection or container changes. Page lifetime must therefore remain independent of visibility and layout changes. + +Independent per-tab storage also prevents related pages in one Workspace from sharing site login state. Page lifetime and storage ownership need different owners. + +## Decision + +Desktop uses `` through `ElectronWebViewImpl`; Web retains its opt-in iframe carrier. The [Sidebar Browser decision](2026-09-16-sidebar-browser.md) owns shared tab behavior and iframe limitations; this note owns the Desktop carrier and Workspace-keyed partitions. + +The [stable Sidebar mounting decision](../architecture/2026-09-20-sidebar-retained-tab-layout.md) owns retained Session and tab containers in real CSS layout. Workspace storage ownership and baseline guest security remain with this note. + +- `BrowserController` owns address commands and recoverable presentation state. Native history stays in the guest; actual URL and title observations update the persisted address. +- Controller registries are keyed by DSH Session, independently of presentation bindings. Rebinding replaces the store writer without recreating the page. +- `electron/pages.ts` composes the Electron navigation provider and presentation object. `ElectronWebViewImpl` owns guest leases and native navigation; `ElectronWebviewPresentation` creates the tag and attaches it inside the Sidebar-owned content container. +- The Desktop Browser type declares `keepMounted`. Sidebar retains its DOM ancestors across hiding and docking; CSS owns layout and clipping. Docking gestures disable guest pointer input, and body-local drop hints use normal stacking. A physical unmount cancels pending attachment and releases the guest; a later mount recreates it from the known address. +- Tab occurrence cancellation, plugin unload and window destruction release guests. Guest crashes leave a retryable failure; Reload creates a new guest. Application restart offers the saved title and URL for explicit restoration; it creates no guest until the user restores or submits an address. Page memory and native history are not restored. + +The standard `./types` subpath exports the shared lease, reservation, open-request and bridge declarations through a declaration-only `types` condition; Desktop consumers use `import type`. There is no runtime default, extra JavaScript artifact or early Host bundling for these declarations. Preload exposes scoped operations and callbacks, not raw IPC or Electron objects. + +Browser has separate Host and Client compiler programs. Desktop and the Host aggregate reference only `tsconfig.host.json`, which compiles the package root and shared bridge declarations; the Client aggregate references `tsconfig.client.json` for the browser implementation. The package root tsconfig is solution-only. This keeps Client consumers of generated `/remote` declarations out of the Host compilation that precedes Typert generation. + +### Storage ownership + +`DesktopBrowserGuests` assigns a random, non-persistent Electron partition to each canonical CWD account. The Client uses the Host-normalized `WorkspaceView.path`, not the Workspace record UUID, so recreating a Workspace at the same directory does not change its storage account key. Browser tabs whose DSH Sessions resolve to that CWD share the account; Sessions without a resolved Workspace remain separately isolated. Workspace membership is resolved after the Client receives its authoritative baseline and is fixed for a guest occurrence. The CWD key controls sharing, not disk persistence. + +Closing a tab releases its guest, not its account's cookies or storage. Cookies, localStorage, IndexedDB, Service Workers and cache remain partition-owned and subject to normal origin rules. DOM, native history and sessionStorage remain page-owned. Account partitions survive window recreation within the Electron process but do not persist across application exit. + +### Initial guest policy + +Only the primary application window enables `webviewTag`. The main process accepts guest reservations only from that window's application top frame and validates a one-use lease, partition and inert initial `about:blank` document before allowing the guest. The main process replaces renderer-supplied preferences: no Node integration, guest preload, nested webviews, plugins, insecure content, dialogs or drag navigation; sandbox, context isolation and Web security stay enabled. + +Guest Sessions do not register the application protocol or inherit the application's authenticated request forwarding. Permission requests and checks, device access, display capture, downloads, native popup windows and HTTP authentication prompts are denied. Approved direct HTTP(S) popup requests without a POST body are routed through their live lease to a new Sidebar tab; scripted blank-window and POST popup flows remain unsupported. Navigation accepts credential-free HTTP(S); request filtering rejects local-file and privileged schemes and the known DSH Host endpoint, including common loopback aliases. This is not a general private-network or DNS-rebinding firewall. + +The Desktop toolbar has no sandbox-disable switch. The implementation adds no remote-debugging endpoint or browser-use integration. A future automation provider requires an authenticated, target-scoped broker rather than access to every application target. The separation of a target/lifetime handle from navigation commands follows the same distinction as Playwright's Android WebView and Page objects; device-level input is a separate responsibility. + +## Alternatives considered + +**Render the guest in a visibility-mounted tab body.** Unmounting disconnects the guest. Direct mounting requires the Sidebar's retained body and stable ancestor mechanism. + +**Move the guest between visible and hidden DOM containers.** Electron 44's `WebViewElement.disconnectedCallback` detaches the guest and resets its internal instance. Retaining the element reference or React key does not preserve that instance; the parent stays fixed instead. + +**Use one partition per tab.** This isolates site accounts between related pages. Workspace ownership provides the requested sharing without sharing the application Session; a tab still owns its own guest. + +**Change the partition of an active webview.** Electron fixes the partition before first navigation. Selecting another isolation scope requires a replacement guest and an explicit policy for existing site data, not a live attribute toggle. + +## Consequences + +Sidebar-owned stable ancestors preserve the page without Browser-owned geometry or occlusion handling. Hidden guests retain page memory and may continue network activity; there is no idle eviction policy. Persistent storage, selectable isolation scopes and permission-grant UI remain separate work. Temporary Workspace partitions are the current policy, not a promise that Workspace isolation always implies temporary storage. + +Focused tests cover native navigation error recovery, preload listener scoping and migrated iframe behavior. They do not establish real Electron attachment timing, overlap, focus, platform styling or storage isolation; those remain runtime verification gaps. No GUI recording accompanies the change. This implementation is not evidence that the complete browser security policy is ready for release. + +The package's Electron provider, presentation, page factory and Workspace storage resolver live in `src/client/electron/`. That directory is temporarily excluded from per-file coverage until a native Electron harness covers guest behavior; its unit tests still run. Shared Browser controllers, restoration UI, iframe code and Sidebar retention remain subject to the existing coverage requirements. diff --git a/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.zh.md b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.zh.md new file mode 100644 index 0000000000..b0ab8b1a70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-20-desktop-browser-webview.zh.md @@ -0,0 +1,59 @@ +# Agent Note: 保持页面实例的 Desktop 浏览器 webview + +Status: implemented + +[English](2026-09-20-desktop-browser-webview.md) | 中文 + +## Problem + +iframe 无法提供跨域导航状态, 也无法显示拒绝嵌入的站点. 如果 Tab Body 因选中项或容器变化而卸载, Electron guest 就会丢失表单、滚动位置与原生 history. 页面生命周期因此必须独立于可见性和布局变化. + +独立的 per-tab 存储也会阻止同一 Workspace 内相关页面共享站点登录态。页面生命周期与存储需要不同的所有者。 + +## Decision + +Desktop 通过 `ElectronWebViewImpl` 使用 ``; Web 保留显式启用的 iframe 载体. [Sidebar Browser 决策](2026-09-16-sidebar-browser.zh.md)负责共享 Tab 行为与 iframe 限制; 本文负责 Desktop 载体和按 Workspace 共享的分区. + +[Sidebar 稳定挂载决策](../architecture/2026-09-20-sidebar-retained-tab-layout.zh.md)负责真实 CSS 布局中的保活会话与 Tab 容器。Workspace 存储所有权与 guest 基础安全配置仍由本文负责。 + +- `BrowserController` 负责地址命令与可恢复的展示状态。原生 history 留在 guest 内;真实 URL 与标题观察更新持久化地址。 +- 控制器注册表按 DSH Session 索引,不依赖呈现绑定的存活期。重新绑定只替换存储写入方,不重建页面。 +- `electron/pages.ts` 组装 Electron 导航提供方和呈现对象. `ElectronWebViewImpl` 负责 guest 租约与原生导航; `ElectronWebviewPresentation` 创建标签并挂载到 Sidebar 持有的内容容器内. +- Desktop Browser 类型声明 `keepMounted`。Sidebar 在隐藏和停靠切换时保留其 DOM 祖先,CSS 负责布局与裁剪。停靠手势期间禁用 guest 指针输入,Body 内的放置提示使用普通层叠。物理卸载会取消未完成的挂载并释放 guest,之后重新挂载时从已知地址重建。 +- tab occurrence 取消、插件卸载与窗口销毁会释放 guest。guest 崩溃留下可重试的失败状态;刷新创建新 guest。应用重启展示保存的标题与 URL,等待显式恢复;用户恢复或提交地址之前不创建 guest。不恢复页面内存或原生 history。 + +标准 `./types` 子路径通过仅指向声明文件的 `types` 条件导出共享的租约、申请结果、打开请求与桥接声明,Desktop 消费方使用 `import type`。这些声明没有运行时 default、额外 JavaScript 产物或提前进行的 Host 打包。preload 只暴露限定范围的操作与回调,不暴露原始 IPC 或 Electron 对象。 + +Browser 分别拥有 Host 与 Client 编译程序。Desktop 和 Host 聚合配置只引用 `tsconfig.host.json`,编译包根与共享桥接声明;Client 聚合配置通过 `tsconfig.client.json` 编译浏览器实现。包根 tsconfig 只作为 solution。这样,依赖生成 `/remote` 声明的 Client 消费方不会进入 Typert 生成之前的 Host 编译阶段。 + +### Storage ownership + +`DesktopBrowserGuests` 为每个规范化 CWD 存储账号分配随机、非持久化的 Electron partition。Client 使用 Host 规范化后的 `WorkspaceView.path`,不使用 Workspace 记录 UUID,因此在同一目录重建 Workspace 不改变存储账号 key。DSH Session 解析到同一 CWD 的 Browser Tab 共享账号;没有可解析 Workspace 的 Session 仍单独隔离。Workspace 归属在 Client 收到权威基线后解析,并在一次 guest occurrence 内保持不变。CWD key 控制共享关系,不决定是否落盘。 + +关闭 tab 只释放它的 guest,不清空账号的 Cookie 或存储。Cookie、localStorage、IndexedDB、Service Worker 与缓存由 partition 持有,并继续遵循普通 origin 规则。DOM、原生 history 与 sessionStorage 仍由页面持有。账号 partition 在同一 Electron 进程内重建窗口后仍保留,但不跨应用退出持久化。 + +### Initial guest policy + +只有主应用窗口启用 `webviewTag`。主进程只接受该窗口应用顶层 frame 发起的 guest 申请,并在放行前校验一次性租约、partition 与无活动内容的初始 `about:blank` 文档。主进程替换 renderer 提供的偏好:不启用 Node integration、guest preload、嵌套 webview、plugin、不安全内容、模态对话框或拖放导航;sandbox、context isolation 与 Web security 保持开启。 + +guest Session 不注册应用协议,也不继承应用的认证请求转发。权限请求与检查、设备访问、屏幕捕获、下载、原生弹窗与 HTTP 认证提示全部拒绝。通过检查且不带 POST body 的直接 HTTP(S) 弹窗请求,通过活动租约路由为新 Sidebar Tab;脚本操作空白窗口和 POST 弹窗流程仍不支持。导航接受不带内嵌凭据的 HTTP(S);请求过滤拒绝本地文件、特权协议与已知 DSH Host 地址,包括常见 loopback 别名。这不是通用的私有网络或 DNS rebinding 防火墙。 + +Desktop toolbar 没有关闭 sandbox 的开关。实现不增加远程调试端口或 browser-use 集成。未来的自动化提供方需要经过认证、限定 target 的 broker,而不是访问全部应用 target。把 target/生命周期句柄与导航命令分开的方式,与 Playwright 的 Android WebView 和 Page 对象区分相同;设备级输入属于另一项职责。 + +## Alternatives considered + +**在按可见性挂载的 tab body 内渲染 guest。** 卸载会断开 guest;直接挂载需要 Sidebar 提供 Body 保活与稳定祖先机制。 + +**在可见与隐藏 DOM 容器之间搬移 guest。** Electron 44 的 `WebViewElement.disconnectedCallback` 会 detach guest 并重置内部实例。保留元素引用或 React key 不能保留该实例,因此父节点保持固定。 + +**每个 tab 使用独立 partition。** 这会隔离相关页面的站点账号。Workspace 所有权提供所需的共享,同时不共享应用 Session;每个 tab 仍拥有独立 guest。 + +**修改活动 webview 的 partition。** Electron 在首次导航前确定 partition。选择其他隔离粒度需要替换 guest,并明确已有站点数据的处置策略,而不是实时切换属性。 + +## Consequences + +Sidebar 持有的稳定祖先保留页面,无需 Browser 自行处理几何或遮挡。隐藏 guest 保留页面内存,也可能继续联网;当前没有空闲回收策略。持久化存储、可选择的隔离级别与权限授权 UI 留作独立工作。临时 Workspace partition 是当前策略,并不意味着 Workspace 隔离必然要求临时存储。 + +定向测试覆盖原生导航错误恢复、preload 监听范围和已迁移的 iframe 行为。这些测试不能确认真实 Electron 的挂载时序、遮挡、焦点、平台样式或存储隔离;这些仍是运行时验证缺口。本次变更不附带 GUI 录像。此实现不构成完整浏览器安全策略已经可以发布的证据。 + +包内的 Electron 提供方、呈现适配器、页面工厂与 Workspace 存储解析集中在 `src/client/electron/`. 在原生 Electron 测试环境覆盖 guest 行为之前, 该目录暂时不参与逐文件覆盖率门禁; 其单元测试仍然运行. 共享 Browser 控制器、恢复 UI、iframe 代码和 Sidebar 保活仍遵循现有覆盖率要求. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index ab890ca559..2ad45f06c7 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -126,6 +126,7 @@ External packages installed for runtime use or distributed inside the prebuilt b pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification: +- `@earendil-works/pi-ai@0.85.1` — [`patches/@earendil-works__pi-ai@0.85.1.patch`](patches/@earendil-works__pi-ai@0.85.1.patch) - `@electron/osx-sign@1.3.3` — [`patches/@electron__osx-sign@1.3.3.patch`](patches/@electron__osx-sign@1.3.3.patch) - `@yao-pkg/pkg@6.21.0` — [`patches/@yao-pkg__pkg@6.21.0.patch`](patches/@yao-pkg__pkg@6.21.0.patch) - `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 692cad3ebd..1122a1c36e 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -369,6 +369,23 @@ describe('the shipped Web composition', () => { expect(tools).not.toContain('str_replace_editor') expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined() + // The persona is `standard`'s, pinned verbatim so the two composition + // files cannot drift apart: tool descriptions and the skill catalog + // carry every creation-mode instruction. + const standard = await ctx.agents.create({ + sessionId: SessionId('preset-cordis-standard-persona'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'standard').then(() => undefined), + }) + try { + const persona = async (agent: Agent) => (await ctx.systemPrompt.assemble({ scope: agent })).sections + .filter(section => section.name.startsWith('deployment:persona-')) + const cordisPersona = await persona(handle.agent) + expect(cordisPersona.map(section => section.name)).toEqual(['deployment:persona-prefix', 'deployment:persona-suffix']) + expect(cordisPersona).toEqual(await persona(standard.agent)) + } finally { + await standard.dispose() + } + // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. const scoped = (await ctx.skills.list({ scope: handle.agent })).map(skill => skill.name) diff --git a/apps/desktop-host/src/update-tasks.ts b/apps/desktop-host/src/update-tasks.ts index 106869532f..2b2bbf4a33 100644 --- a/apps/desktop-host/src/update-tasks.ts +++ b/apps/desktop-host/src/update-tasks.ts @@ -45,7 +45,7 @@ export function installDesktopUpdateTaskControl(ctx: Context): (action: 'inspect const liveAgents = agents.list() return liveAgents.some(agent => agent.status === 'running' || agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0) - || [undefined, ...liveAgents].some(agent => jobs.list(agent) + || [undefined, ...liveAgents].some(agent => jobs.list(agent?.id) .some(job => job.status === 'running' || job.status === 'stopping')) } } diff --git a/apps/desktop/README.i18n.yaml b/apps/desktop/README.i18n.yaml index b536269a93..81f1df65f7 100644 --- a/apps/desktop/README.i18n.yaml +++ b/apps/desktop/README.i18n.yaml @@ -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 apps/desktop/README.md -README.md: 48ad6bbcf515aac5734da1fd406ce70f0f4d6505 -README.zh.md: 795e62a0e119e40211387a12f9e4fa56fdde3e79 +README.md: 43cde4f41f2e82bf2d3ccce0a7e93d81234010c5 +README.zh.md: 9e300996cd7ed0428c7199e9f9bd43b6f9077e0a diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 48ad6bbcf5..43cde4f41f 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -10,6 +10,8 @@ Desktop’s local native directory flow opens an Electron folder dialog attached Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations. +Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description. + ## Key technical decisions The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation. @@ -46,7 +48,9 @@ The [thin-wrapper decision](../../.agents/notes/implemented/architecture/2026-09 Electron owns `$DSH_HOME/profiles/desktop`. Its `dependencies` contains packages installed by pnpm; `dsh.profile.bundles` contains the built-in bundles followed by enabled plugins. The signed application supplies dsh, the private Desktop Host, and their production packages from `resources/app.asar/dsh`. Packaged applications select runtime profile resolution without creating package links; development profiles use filesystem links. Both host and plugins execute in the same Electron Node-mode process; Desktop does not enable `--preserve-symlinks`. The CLI cannot boot or mutate this profile. -The application preload exposes boot readiness, fatal startup reporting, and native directory selection. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. +The application preload exposes boot readiness, fatal startup reporting, native directory selection, the `__DSH_HOST_PATHS__` bridge for composer path references, and the lease-scoped Browser bridge only to `dsh-app://app` documents. Product documents use the shared authenticated HTTP APIs and receive the Desktop marker, update presentation, and an action that opens native confirmation; they cannot choose artifacts or authorize installation. Plugin management uses the Web application's authenticated HTTP APIs. Electron serves update-dialog documents and assets locally at `dsh-app://shell/`, independently of Host readiness. Electron exposes no plugin-management IPC or separate management document. No renderer receives filesystem access, raw Electron IPC, a shell, or arbitrary pnpm arguments. + +Only the main application window enables ``. Guest attachment must match a main-issued lease and partition; guests keep sandbox, context isolation and Web security without Node integration or guest preload. Browser IPC listeners are created only for the application document. [Sidebar Browser](../../packages/client/ui-sidebar-browser/README.md) describes storage grouping and guest limitations; Host authentication remains required independently of URL filtering. The `dsh-app://shell/` origin serves packaged update documents, scripts, and styles without contacting the Host. Static requests retain GET/HEAD, path-containment, and MIME handling; each update document keeps its isolated preload and owned-window IPC checks. diff --git a/apps/desktop/README.zh.md b/apps/desktop/README.zh.md index 795e62a0e1..9e300996cd 100644 --- a/apps/desktop/README.zh.md +++ b/apps/desktop/README.zh.md @@ -10,6 +10,8 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件 Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。 +桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。 + ## 关键技术决策 设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。 @@ -46,7 +48,9 @@ Node 准备内置解释器和 Python 库,无需系统 Python 或 pip。[下载 Electron 拥有 `$DSH_HOME/profiles/desktop`。其 `dependencies` 包含 pnpm 安装的包;`dsh.profile.bundles` 包含内置 bundle,后接已启用插件。签名应用从 `resources/app.asar/dsh` 提供 dsh、私有 Desktop Host 及其生产依赖。打包应用选择 runtime profile 解析,不创建包链接;开发 profile 使用文件系统链接。宿主与插件在同一个 Electron Node 模式进程中执行;Desktop 不启用 `--preserve-symlinks`。CLI 不能启动或修改此 profile。 -应用 preload 暴露启动就绪、致命启动失败上报和原生目录选择。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 +应用 preload 只向 `dsh-app://app` 文档暴露启动就绪、致命启动失败上报、原生目录选择、用于 composer 路径引用的 `__DSH_HOST_PATHS__` 桥接和租约范围内的 Browser 桥接。产品页面还获得 Desktop 标记、更新展示数据和打开原生确认的操作,不能选择安装产物或授权安装。插件管理使用 Web 应用经过认证的 HTTP API;Electron 在 `dsh-app://shell/` 本地提供更新弹窗文档和资源,不依赖 Host 就绪。Electron 不提供插件管理 IPC 或独立管理页面。 + +只有主应用窗口启用 ``。guest 挂载必须匹配主进程签发的租约和分区;guest 保持 sandbox、context isolation 和 Web security,不启用 Node integration 或 guest preload。Browser IPC 监听只为应用文档创建。[Sidebar Browser](../../packages/client/ui-sidebar-browser/README.zh.md) 说明存储分组和 guest 限制;Host 鉴权仍独立于 URL 过滤而必需。 `dsh-app://shell/` 无需联系 Host 即可提供打包的更新文档、脚本和样式。静态请求保留 GET/HEAD、路径范围和 MIME 处理;每个更新文档继续使用隔离 preload 和所属窗口的 IPC 校验。 diff --git a/apps/desktop/electron-builder.config.d.mts b/apps/desktop/electron-builder.config.d.mts index f7cfd63f42..632250119b 100644 --- a/apps/desktop/electron-builder.config.d.mts +++ b/apps/desktop/electron-builder.config.d.mts @@ -21,6 +21,7 @@ export interface DesktopElectronBuilderConfig { { readonly from: string, readonly to: 'icon.png' }, ] readonly mac: { + readonly extendInfo: { readonly NSMicrophoneUsageDescription: string } readonly identity: string | undefined readonly forceCodeSigning: boolean readonly notarize: boolean diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b9cb4b3592..e9850c256e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,6 +41,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings-general": "workspace:^", + "@deepseek-ai/dsh-client-ui-sidebar-browser": "workspace:^", "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/node-addon-system": "workspace:^", "@electron/get": "^5.1.0", diff --git a/apps/desktop/scripts/electron-builder-config.mjs b/apps/desktop/scripts/electron-builder-config.mjs index dd4b00d24c..3f469c3be2 100644 --- a/apps/desktop/scripts/electron-builder-config.mjs +++ b/apps/desktop/scripts/electron-builder-config.mjs @@ -131,6 +131,7 @@ export function createElectronBuilderConfig( identity: macOSSigning?.signingIdentity, forceCodeSigning: true, hardenedRuntime: true, + extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' }, // ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle. signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'], notarize: true, diff --git a/apps/desktop/src/browser-guests.ts b/apps/desktop/src/browser-guests.ts new file mode 100644 index 0000000000..027b7cee5b --- /dev/null +++ b/apps/desktop/src/browser-guests.ts @@ -0,0 +1,166 @@ +/** Main-process ownership and fixed isolation policy for Sidebar webview guests. */ +import { randomUUID } from 'node:crypto' +import { app, session, type BrowserWindow, type Session, type WebContents } from 'electron' +import type { DesktopBrowserLeaseId, DesktopBrowserOpenRequest, DesktopBrowserReservation } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { DESKTOP_IPC } from './ipc.ts' + +interface GuestLease { + readonly owner: WebContents + readonly partition: string + attached: boolean + guest?: WebContents +} + +/** Owns workspace storage partitions independently from individual tab guests. */ +export class DesktopBrowserGuests { + private readonly partitions = new Map() + private readonly leases = new Map() + + /** @param hostUrl - current authenticated DSH Host, which guests cannot request. */ + constructor(private readonly hostUrl: () => string | undefined) {} + + /** + * Reserve one guest in a workspace's process-lifetime partition. + * @param owner - authenticated primary application WebContents. + * @param workspace - workspace identity received over IPC. + * @returns opaque lease and the partition approved for it. + */ + acquire(owner: WebContents, workspace: unknown): DesktopBrowserReservation { + if (typeof workspace !== 'string' || workspace.length === 0 || workspace.length > 4096) { + throw new Error('desktop browser: a workspace storage identity is required') + } + let partition = this.partitions.get(workspace) + if (partition === undefined) { + partition = `dsh-sidebar-browser-${randomUUID()}` + this.configureSession(session.fromPartition(partition)) + this.partitions.set(workspace, partition) + } + const lease = randomUUID() as DesktopBrowserLeaseId + this.leases.set(lease, { owner, partition, attached: false }) + return { lease, partition } + } + + /** + * Release only a lease issued to this application window; workspace storage survives. + * @param owner - authenticated IPC sender. + * @param id - lease received over IPC. + */ + async release(owner: WebContents, id: unknown): Promise { + if (typeof id !== 'string') throw new Error('desktop browser: invalid guest lease') + const key = id as DesktopBrowserLeaseId + const lease = this.leases.get(key) + if (lease === undefined) return + if (lease.owner !== owner) throw new Error('desktop browser: guest belongs to another window') + this.leases.delete(key) + const guest = lease.guest + if (guest !== undefined && !guest.isDestroyed()) { + const destroyed = new Promise((resolve) => { guest.once('destroyed', resolve) }) + guest.close({ waitForBeforeUnload: false }) + await destroyed + } + } + + /** + * Install attachment checks before the application document can create a webview. + * @param window - primary application window. + */ + bind(window: BrowserWindow): void { + const owner = window.webContents + owner.on('will-attach-webview', (event, preferences, params) => { + const id = typeof params.src === 'string' && params.src.startsWith('about:blank#') + ? params.src.slice('about:blank#'.length) : '' + const lease = this.leases.get(id as DesktopBrowserLeaseId) + if (lease === undefined || lease.owner !== owner || lease.attached || params.partition !== lease.partition) { + event.preventDefault() + return + } + lease.attached = true + // Keep Electron's allowpopups dispatch flag; the guest handler still denies native windows. + for (const key of Object.keys(preferences)) { + if (key !== 'disablePopups') Reflect.deleteProperty(preferences, key) + } + Object.assign(preferences, { + partition: lease.partition, + nodeIntegration: false, nodeIntegrationInWorker: false, nodeIntegrationInSubFrames: false, + contextIsolation: true, sandbox: true, webSecurity: true, allowRunningInsecureContent: false, + webviewTag: false, plugins: false, navigateOnDragDrop: false, disableDialogs: true, + devTools: !app.isPackaged, + }) + params.httpreferrer = '' + }) + owner.on('did-attach-webview', (_event, guest) => { + let attachedLease: DesktopBrowserLeaseId | undefined + // The first document is an inert about:blank carrying the approved lease. + // Bind on the main-process event before the renderer can navigate the ready guest. + guest.once('dom-ready', () => { + const url = guest.getURL() + const id = (url.startsWith('about:blank#') ? url.slice('about:blank#'.length) : '') as DesktopBrowserLeaseId + const lease = this.leases.get(id) + if (lease === undefined || lease.owner !== owner || lease.guest !== undefined) { + guest.close({ waitForBeforeUnload: false }) + return + } + lease.guest = guest + attachedLease = id + guest.once('destroyed', () => { this.leases.delete(id) }) + }) + guest.setWindowOpenHandler(({ url, postBody }) => { + const lease = attachedLease === undefined ? undefined : this.leases.get(attachedLease) + if (attachedLease !== undefined && lease?.guest === guest && lease.owner === owner && !owner.isDestroyed() + && postBody === undefined && this.allowedNavigation(url)) { + const request: DesktopBrowserOpenRequest = { lease: attachedLease, url: new URL(url).href } + owner.send(DESKTOP_IPC.browserOpenRequested, request) + } + return { action: 'deny' } + }) + guest.on('will-frame-navigate', (event) => { + if (event.isMainFrame && !this.allowedNavigation(event.url)) event.preventDefault() + }) + guest.on('will-redirect', (event, url, _inPlace, mainFrame) => { + if (mainFrame && !this.allowedNavigation(url)) event.preventDefault() + }) + guest.on('will-attach-webview', (event) => { event.preventDefault() }) + guest.on('login', (event, _details, _authInfo, callback) => { event.preventDefault(); callback() }) + }) + const releaseAll = (): void => { + for (const [id, lease] of this.leases) { + if (lease.owner === owner) void this.release(owner, id).catch((error: unknown) => { console.error(error) }) + } + } + owner.on('did-start-navigation', (_event, _url, inPlace, mainFrame) => { + if (mainFrame && !inPlace) releaseAll() + }) + owner.on('render-process-gone', releaseAll) + owner.once('destroyed', releaseAll) + } + + private configureSession(browserSession: Session): void { + browserSession.setPermissionRequestHandler((_contents, _permission, callback) => { callback(false) }) + browserSession.setPermissionCheckHandler(() => false) + browserSession.setDevicePermissionHandler(() => false) + browserSession.setDisplayMediaRequestHandler((_request, callback) => { callback({}) }) + browserSession.on('will-download', (event) => { event.preventDefault() }) + browserSession.webRequest.onBeforeRequest((details, callback) => { + const url = new URL(details.url) + const network = ['http:', 'https:', 'ws:', 'wss:'].includes(url.protocol) + callback({ cancel: network + ? url.username !== '' || url.password !== '' || this.isApplicationHost(url) + : !['about:', 'data:', 'blob:'].includes(url.protocol) }) + }) + } + + private allowedNavigation(value: string): boolean { + if (!URL.canParse(value)) return false + const url = new URL(value) + return ['http:', 'https:'].includes(url.protocol) && url.username === '' && url.password === '' + && !this.isApplicationHost(url) + } + + private isApplicationHost(url: URL): boolean { + const value = this.hostUrl() + if (value === undefined) return false + const host = new URL(value) + return url.port === host.port + && (url.hostname === host.hostname || ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) + } +} diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index f5f1f6e82e..ea77749109 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -1,11 +1,15 @@ /** Typed preload operations exposed only by the Electron shell. */ import type { IpcMainInvokeEvent } from 'electron' +import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' /** IPC channel names kept private to the desktop application bundle. */ export const DESKTOP_IPC = { boot: 'dsh-desktop:boot', bootFailed: 'dsh-desktop:boot-failed', + browserAcquire: 'dsh-desktop:browser-acquire', + browserRelease: 'dsh-desktop:browser-release', + browserOpenRequested: 'dsh-desktop:browser-open-requested', directoryPick: 'dsh-desktop:directory-pick', updatesStatus: 'dsh-desktop:updates-status', updatesOpen: 'dsh-desktop:updates-open', @@ -54,6 +58,7 @@ export interface DesktopUpdatePresentation { /** Product documents cannot supply update versions, package URLs, or installation authorization. */ export interface DshDesktopProductApi { readonly protocolVersion: 1 + readonly browser: DesktopBrowserBridge readonly updates: { status(): Promise open(): Promise diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 7531a7cfa9..b79df71ff2 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -22,6 +22,7 @@ import { resolveDesktopPaths } from './paths.ts' import { DesktopProjectManager } from './project-manager.ts' import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts' import { installDesktopDirectoryPicker } from './directory-picker.ts' +import { installMicrophonePermissions } from './microphone-permissions.ts' import { DesktopBackendController } from './backend-controller.ts' import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts' import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts' @@ -39,6 +40,7 @@ import { DesktopMandatoryUpdateWindow } from './mandatory-update-window.ts' import { DesktopPolicyTestAuth } from './policy-test-auth.ts' import { DesktopUpdateDialog, type UpdateDialogOptions } from './update-dialog.ts' import { readDesktopRuntime } from './runtime-tree.ts' +import { DesktopBrowserGuests } from './browser-guests.ts' let focusPrimaryWindow = (): void => {} let stopForRecovery = async (): Promise => {} @@ -137,6 +139,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi contextIsolation: true, sandbox: true, webSecurity: true, + webviewTag: primary, }, }) window.webContents.setWindowOpenHandler(({ url }) => { @@ -234,6 +237,7 @@ async function main(): Promise { const applicationUrl = `${SCHEME}://app/` let hostUrl: string | undefined let hostCookie: string | undefined + const browserGuests = new DesktopBrowserGuests(() => hostUrl) let injections: readonly unknown[] = [] const assertProductSender = (event: IpcMainInvokeEvent): void => { assertDesktopSender(event, ['app']) @@ -426,6 +430,7 @@ async function main(): Promise { }) installDesktopDirectoryPicker(() => mainWindow) + installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents) ipcMain.handle(DESKTOP_IPC.boot, async (event) => { assertDesktopSender(event, ['app']) @@ -443,6 +448,15 @@ async function main(): Promise { reportFatal(new Error(message)) }) + ipcMain.handle(DESKTOP_IPC.browserAcquire, (event, workspace: unknown) => { + assertProductSender(event) + return browserGuests.acquire(event.sender, workspace) + }) + ipcMain.handle(DESKTOP_IPC.browserRelease, (event, lease: unknown) => { + assertProductSender(event) + return browserGuests.release(event.sender, lease) + }) + session.defaultSession.webRequest.onBeforeSendHeaders({ urls: ['ws://127.0.0.1/*'] }, (details, callback) => { if (hostUrl === undefined || hostCookie === undefined || details.webContentsId !== mainWindow?.webContents.id) { callback({}) @@ -684,6 +698,7 @@ async function main(): Promise { const createMainWindow = (): BrowserWindow => { const window = createWindow(appPreload, true, true) mainWindow = window + browserGuests.bind(window) window.on('focus', automaticCheck) window.on('closed', () => { if (mainWindow === window) mainWindow = undefined }) window.webContents.on('did-fail-load', (_event, code, description, url, isMainFrame) => { diff --git a/apps/desktop/src/microphone-permissions.ts b/apps/desktop/src/microphone-permissions.ts new file mode 100644 index 0000000000..4d41a8e390 --- /dev/null +++ b/apps/desktop/src/microphone-permissions.ts @@ -0,0 +1,31 @@ +/** Microphone access belongs to the primary application frame and the operating system. */ +import { systemPreferences, type Session, type WebContents } from 'electron' + +function applicationFrame(url: string): boolean { + try { + const parsed = new URL(url) + return parsed.protocol === 'dsh-app:' && parsed.hostname === 'app' + } catch (_error) { return false /* Invalid frame URLs cannot request microphone access. */ } +} + +/** + * Handle microphone requests from the owned application window; other permissions retain Electron's defaults. + * @param session - application's browser session. + * @param primary - current primary window contents, absent while no window is open. + */ +export function installMicrophonePermissions(session: Pick, primary: () => WebContents | undefined): void { + session.setPermissionCheckHandler((contents, permission, origin, details) => { + if (permission !== 'media') return true + return contents != null && contents === primary() && details.isMainFrame + && applicationFrame(origin) && details.mediaType === 'audio' + && (process.platform !== 'darwin' || systemPreferences.getMediaAccessStatus('microphone') === 'granted') + }) + session.setPermissionRequestHandler((contents, permission, callback, details) => { + if (permission !== 'media') { callback(true); return } + const allowed = contents === primary() && details.isMainFrame && applicationFrame(details.requestingUrl) + && 'mediaTypes' in details && details.mediaTypes.length === 1 && details.mediaTypes[0] === 'audio' + if (!allowed) { callback(false); return } + if (process.platform !== 'darwin') { callback(true); return } + void systemPreferences.askForMediaAccess('microphone').then(callback, () => { callback(false) }) + }) +} diff --git a/apps/desktop/src/preload-app.ts b/apps/desktop/src/preload-app.ts index 3baf8dd2b9..8133c72a7c 100644 --- a/apps/desktop/src/preload-app.ts +++ b/apps/desktop/src/preload-app.ts @@ -1,23 +1,27 @@ -/** Origin-scoped boot, native directory selection, and update presentation with native confirmation actions. */ +/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */ -import { contextBridge, ipcRenderer } from 'electron' +import { contextBridge, ipcRenderer, webUtils } from 'electron' import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts' import { markDocumentPlatform, syncWindowFullscreen } from './preload-platform.ts' import { syncNativeTheme } from './preload-theme.ts' import { syncWindowsAppearance } from './preload-windows.ts' import { installMandatoryUpdateOverlay } from './preload-mandatory-overlay.ts' +import { createDesktopBrowserBridge } from './preload-browser.ts' -const product: DshDesktopProductApi = { - protocolVersion: 1, - updates: { - status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, - open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, - subscribe(listener) { - const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) } - ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle) - return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) } +function createProductApi(): DshDesktopProductApi { + return { + protocolVersion: 1, + browser: createDesktopBrowserBridge(), + updates: { + status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise, + open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise, + subscribe(listener) { + const handle = (_event: Electron.IpcRendererEvent, state: DesktopUpdatePresentation): void => { listener(state) } + ipcRenderer.on(DESKTOP_IPC.updatesPresentation, handle) + return () => { ipcRenderer.off(DESKTOP_IPC.updatesPresentation, handle) } + }, }, - }, + } } if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { @@ -26,6 +30,12 @@ if (location.protocol === `${SCHEME}:` && location.hostname === 'app') { contextBridge.exposeInMainWorld('__DSH_DIRECTORY_PICKER__', { pick: () => ipcRenderer.invoke(DESKTOP_IPC.directoryPick) as Promise, }) + // The composer cites dropped, picked, and pasted files and folders that + // have a real path as `@path` references instead of uploading them; a + // File without one (pasted bytes) answers '' and uploads as before. + contextBridge.exposeInMainWorld('__DSH_HOST_PATHS__', { + pathFor: (file: File) => webUtils.getPathForFile(file), + }) contextBridge.exposeInMainWorld('dshDesktopBoot', { ready: () => ipcRenderer.invoke(DESKTOP_IPC.boot) as Promise, failed: (message: string) => ipcRenderer.invoke(DESKTOP_IPC.bootFailed, message) as Promise, @@ -36,4 +46,4 @@ markDocumentPlatform() syncWindowFullscreen() syncNativeTheme() // Main-process IPC also verifies the owning window and top frame. -contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? product : { protocolVersion: 1 }) +contextBridge.exposeInMainWorld('dshDesktop', location.protocol === `${SCHEME}:` && location.hostname === 'app' ? createProductApi() : { protocolVersion: 1 }) diff --git a/apps/desktop/src/preload-browser.ts b/apps/desktop/src/preload-browser.ts new file mode 100644 index 0000000000..fadf2f1ce1 --- /dev/null +++ b/apps/desktop/src/preload-browser.ts @@ -0,0 +1,32 @@ +/** Lease-scoped browser operations and one main-process event subscription per window. */ +import { ipcRenderer } from 'electron' +import type { DesktopBrowserBridge, DesktopBrowserLeaseId } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types' +import { DESKTOP_IPC } from './ipc.ts' + +/** @returns browser operations that expose neither IPC nor Electron objects. */ +export function createDesktopBrowserBridge(): DesktopBrowserBridge { + const listeners = new Map void>>() + ipcRenderer.on(DESKTOP_IPC.browserOpenRequested, (_event, request: unknown) => { + if (typeof request !== 'object' || request === null || !('lease' in request) || !('url' in request) + || typeof request.lease !== 'string' || typeof request.url !== 'string') return + const callbacks = listeners.get(request.lease as DesktopBrowserLeaseId) + if (callbacks === undefined) return + for (const callback of [...callbacks]) { + try { callback(request.url) } + catch (error) { console.error('Desktop browser link handler failed', error) } + } + }) + return { + acquire: workspace => ipcRenderer.invoke(DESKTOP_IPC.browserAcquire, workspace) as ReturnType, + release: lease => ipcRenderer.invoke(DESKTOP_IPC.browserRelease, lease) as Promise, + onOpenRequested(lease, listener) { + let callbacks = listeners.get(lease) + if (callbacks === undefined) { callbacks = new Set(); listeners.set(lease, callbacks) } + callbacks.add(listener) + return () => { + callbacks.delete(listener) + if (callbacks.size === 0 && listeners.get(lease) === callbacks) listeners.delete(lease) + } + }, + } +} diff --git a/apps/desktop/tests/host-update-tasks.spec.ts b/apps/desktop/tests/host-update-tasks.spec.ts index 086aaa9a26..7e1ccc4663 100644 --- a/apps/desktop/tests/host-update-tasks.spec.ts +++ b/apps/desktop/tests/host-update-tasks.spec.ts @@ -1,16 +1,41 @@ import { Context } from '@deepseek-ai/cordis' +import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobStatus, JobView } from '@deepseek-ai/dsh-jobs' +import { SessionId } from '@deepseek-ai/dsh-session/types' import { Readable } from 'node:stream' import type { IncomingMessage, ServerResponse } from 'node:http' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { installDesktopUpdateTaskControl } from '../../desktop-host/src/update-tasks.ts' -type AgentState = { status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } } -type JobState = { status: 'running' | 'stopping' | 'finished' } +type AgentState = { id: SessionId; status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } } let ctx: Context let inspect: ReturnType const agents: AgentState[] = [] -const jobs = new Map() +const jobs = new Map() + +/** A registry whose only served member is the per-owner roster the inspector reads. */ +class RosterOnlyJobRegistry extends JobRegistry { + readonly events = { subscribe: () => () => {} } + start(): never { throw new Error('unsupported') } + list(caller?: SessionId): JobView[] { + return (jobs.get(caller) ?? []).map((status, index) => ({ + id: JobId(`bash-${index + 1}`), + kind: 'bash', + label: 'sleep 60', + ...caller === undefined ? {} : { owner: caller }, + status, + startedAt: 0, + output: { total: 0, earliest: 0 }, + })) + } + get(): never { throw new Error('unsupported') } + read(): never { throw new Error('unsupported') } + readAt(): never { throw new Error('unsupported') } + kill(): never { throw new Error('unsupported') } + wait(): never { throw new Error('unsupported') } + attachController(): () => void { return () => {} } +} beforeEach(() => { agents.length = 0 @@ -18,13 +43,13 @@ beforeEach(() => { ctx = new Context() // Narrow service doubles exercise the inspector against a real Cordis event lifecycle. ctx.provide('agents', { list: () => agents } as unknown as Context['agents']) - ctx.provide('jobs', { list: (agent?: AgentState) => jobs.get(agent) ?? [] } as unknown as Context['jobs']) + new RosterOnlyJobRegistry(ctx) inspect = installDesktopUpdateTaskControl(ctx) }) afterEach(async () => { await ctx.fiber.dispose() }) -function idleAgent(): AgentState { return { status: 'idle', inbox: { nextTurn: [], nextStep: [] } } } +function idleAgent(): AgentState { return { id: SessionId('update-task-owner'), status: 'idle', inbox: { nextTurn: [], nextStep: [] } } } function request(next: () => Promise) { const incoming = Readable.from([]) as IncomingMessage @@ -48,10 +73,10 @@ describe('Desktop Host update task protection', () => { it.each(['running', 'stopping'] as const)('counts global and agent-owned %s jobs', async (status) => { const agent = idleAgent() agents.push(agent) - for (const owner of [undefined, agent]) { - jobs.set(owner, [{ status }]) + for (const owner of [undefined, agent.id]) { + jobs.set(owner, [status]) expect(await inspect('inspect')).toBe(true) - jobs.set(owner, [{ status: 'finished' }]) + jobs.set(owner, ['completed']) expect(await inspect('inspect')).toBe(false) } }) diff --git a/apps/desktop/tests/macos-signature.spec.ts b/apps/desktop/tests/macos-signature.spec.ts index 4d71d3048b..6cd8faeccc 100644 --- a/apps/desktop/tests/macos-signature.spec.ts +++ b/apps/desktop/tests/macos-signature.spec.ts @@ -42,6 +42,7 @@ describe('desktop macOS release signature', () => { const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs') const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64') expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts') + expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone') expect(config.extraResources).toHaveLength(2) expect(config.extraResources[0]?.to).toBe('runtime') expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime') diff --git a/apps/desktop/tests/main-startup.spec.ts b/apps/desktop/tests/main-startup.spec.ts index 8547bc28fe..9be17e5321 100644 --- a/apps/desktop/tests/main-startup.spec.ts +++ b/apps/desktop/tests/main-startup.spec.ts @@ -211,7 +211,9 @@ vi.mock('electron', () => ({ removeHandler: (channel: string) => { harness.handlers.delete(channel) }, }, Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu }, - session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } }, + session: { defaultSession: { + setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders }, + } }, protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle }, powerMonitor: harness.powerMonitor, })) diff --git a/apps/desktop/tests/microphone-permissions.spec.ts b/apps/desktop/tests/microphone-permissions.spec.ts new file mode 100644 index 0000000000..200a980f20 --- /dev/null +++ b/apps/desktop/tests/microphone-permissions.spec.ts @@ -0,0 +1,69 @@ +/** Desktop media permissions exclude external pages, subframes and camera requests. */ +import type { Session, WebContents } from 'electron' +import { afterEach, expect, it, vi } from 'vitest' +import { installMicrophonePermissions } from '../src/microphone-permissions.ts' + +const access = vi.hoisted(() => ({ getMediaAccessStatus: vi.fn(() => 'granted'), askForMediaAccess: vi.fn(async () => true) })) +vi.mock('electron', () => ({ systemPreferences: access })) +afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks() }) +function fixture(platform: NodeJS.Platform = 'darwin') { + vi.stubGlobal('process', { ...process, platform }) + const setPermissionCheckHandler = vi.fn() + const setPermissionRequestHandler = vi.fn() + const primary = {} as WebContents + installMicrophonePermissions({ setPermissionCheckHandler, setPermissionRequestHandler }, () => primary) + return { primary, check: setPermissionCheckHandler.mock.calls[0]![0]!, request: setPermissionRequestHandler.mock.calls[0]![0]! } +} +it('allows only audio checks from the owned primary application frame', () => { + const f = fixture(), details = { isMainFrame: true, mediaType: 'audio' as const } + expect(f.check(f.primary, 'media', 'dsh-app://app', details)).toBe(true) + expect(f.check(null, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check({} as WebContents, 'media', 'dsh-app://app', details)).toBe(false) + expect(f.check(f.primary, 'media', 'https://example.com', details)).toBe(false) + expect(f.check(f.primary, 'media', 'invalid', details)).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, isMainFrame: false })).toBe(false) + expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, mediaType: 'video' })).toBe(false) + expect(f.check(f.primary, 'clipboard-sanitized-write', 'dsh-app://app', details)).toBe(true) +}) +it.each(['not-determined', 'granted', 'denied', 'restricted', 'unknown'] as const)( + 'reports existing macOS microphone authorization for %s', (status) => { + const f = fixture() + access.getMediaAccessStatus.mockReturnValueOnce(status) + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(status === 'granted') + expect(access.getMediaAccessStatus).toHaveBeenCalledExactlyOnceWith('microphone') + expect(access.askForMediaAccess).not.toHaveBeenCalled() + }, +) +it.each([true, false])('waits for the first macOS authorization decision: %s', async (allowed) => { + const f = fixture(), decision = Promise.withResolvers(), done = vi.fn() + access.getMediaAccessStatus.mockReturnValue('not-determined') + access.askForMediaAccess.mockReturnValueOnce(decision.promise) + try { + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(access.askForMediaAccess).toHaveBeenCalledExactlyOnceWith('microphone') + expect(done).not.toHaveBeenCalled() + } finally { decision.resolve(allowed); await decision.promise } + expect(done).toHaveBeenCalledExactlyOnceWith(allowed) +}) +it('requests macOS microphone access and reports operating-system rejection', async () => { + const f = fixture(), done = vi.fn(), details = { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] } + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(true) }) + expect(access.askForMediaAccess).toHaveBeenCalledWith('microphone') + access.askForMediaAccess.mockRejectedValueOnce(new Error('permission denied')) + f.request(f.primary, 'media', done, details) + await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(false) }) + for (const denied of [{ ...details, mediaTypes: ['audio', 'video'] }, { ...details, mediaTypes: [] }, + { ...details, isMainFrame: false }, { isMainFrame: true, requestingUrl: 'dsh-app://app/' }]) { + f.request(f.primary, 'media', done, denied); expect(done).toHaveBeenLastCalledWith(false) + } + f.request(f.primary, 'clipboard-sanitized-write', done, details) + expect(done).toHaveBeenLastCalledWith(true) +}) +it.each(['win32', 'linux'] as const)('leaves system authorization to Chromium on %s', (platform) => { + const f = fixture(platform), done = vi.fn() + expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(true) + f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }) + expect(done).toHaveBeenCalledWith(true) + expect(access.askForMediaAccess).not.toHaveBeenCalled() +}) diff --git a/apps/desktop/tests/preload-app.spec.ts b/apps/desktop/tests/preload-app.spec.ts index fb633e8a4a..cdce12e6b4 100644 --- a/apps/desktop/tests/preload-app.spec.ts +++ b/apps/desktop/tests/preload-app.spec.ts @@ -6,6 +6,7 @@ import { DESKTOP_IPC, type DshDesktopProductApi } from '../src/ipc.ts' const electron = vi.hoisted(() => ({ contextBridge: { exposeInMainWorld: vi.fn() }, ipcRenderer: { invoke: vi.fn(), on: vi.fn(), off: vi.fn(), send: vi.fn() }, + webUtils: { getPathForFile: vi.fn() }, })) vi.mock('electron', () => electron) vi.mock('../src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) @@ -27,7 +28,8 @@ it('limits product documents to update status and a native confirmation action', expect(api.updates).not.toHaveProperty('install') const listener = vi.fn() const dispose = api.updates.subscribe(listener) - const handler = electron.ipcRenderer.on.mock.calls[0]?.[1] as (event: unknown, state: unknown) => void + const handler = electron.ipcRenderer.on.mock.calls.find(([channel]) => channel === DESKTOP_IPC.updatesPresentation)?.[1] as + (event: unknown, state: unknown) => void handler({}, { visible: false }) expect(listener).toHaveBeenCalledWith({ visible: false }) dispose() @@ -38,6 +40,7 @@ it.each(['dsh-app://shell/plugin-manager.html', 'dsh-app://other/index.html', 'h vi.stubGlobal('location', new URL(url)) await import('../src/preload-app.ts') expect(electron.contextBridge.exposeInMainWorld).toHaveBeenCalledWith('dshDesktop', { protocolVersion: 1 }) + expect(electron.ipcRenderer.on.mock.calls.some(([channel]) => channel === DESKTOP_IPC.browserOpenRequested)).toBe(false) }) it('exposes asynchronous boot only to the local application document', async () => { @@ -71,6 +74,23 @@ it('exposes a directory picker only to the local application document', async () } }) +it('reports host paths of picked files only to the local application document', async () => { + vi.stubGlobal('location', new URL('dsh-app://app/')) + await import('../src/preload-app.ts') + const api = electron.contextBridge.exposeInMainWorld.mock.calls.find(([name]) => name === '__DSH_HOST_PATHS__')?.[1] as { pathFor(file: File): string } + const picked = new File(['x'], 'notes.md') + electron.webUtils.getPathForFile.mockReturnValue('/Users/me/notes.md') + expect(api.pathFor(picked)).toBe('/Users/me/notes.md') + expect(electron.webUtils.getPathForFile).toHaveBeenCalledExactlyOnceWith(picked) + for (const url of ['dsh-app://shell/startup.html', 'https://example.com/']) { + vi.resetModules() + electron.contextBridge.exposeInMainWorld.mockClear() + vi.stubGlobal('location', new URL(url)) + await import('../src/preload-app.ts') + expect(electron.contextBridge.exposeInMainWorld.mock.calls.some(([name]) => name === '__DSH_HOST_PATHS__')).toBe(false) + } +}) + it.each(['dsh-app://app/', 'dsh-app://shell/plugin-manager.html', 'https://example.com/'])( 'installs Windows appearance only for the application document (%s)', async (url) => { vi.stubGlobal('location', new URL(url)) diff --git a/apps/desktop/tests/update-presentation.spec.ts b/apps/desktop/tests/update-presentation.spec.ts index 70ed16a168..9d3ce5009e 100644 --- a/apps/desktop/tests/update-presentation.spec.ts +++ b/apps/desktop/tests/update-presentation.spec.ts @@ -2,7 +2,7 @@ import { expect, it } from 'vitest' import { desktopUpdateErrorSummary, presentDesktopUpdate } from '../src/update-presentation.ts' import { en, zh } from '../src/locale.ts' import type { DesktopUpdateState, DshDesktopProductApi } from '../src/ipc.ts' -import type { DesktopUpdateBridge } from '../../../packages/client/ui-settings-general/src/client/desktop-update-bridge.ts' +import type { DesktopUpdateBridge } from '@deepseek-ai/dsh-client-ui-settings-general/types' it('keeps the Electron preload assignable to the product consumer', () => { const assign = (bridge: DshDesktopProductApi['updates']): DesktopUpdateBridge => bridge diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 6811108afd..ad14bd9bb9 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -7,6 +7,7 @@ "include": ["src"], "references": [ { "path": "../../packages/boot/app-boot" }, + { "path": "../../packages/client/ui-sidebar-browser/tsconfig.host.json" }, { "path": "../../packages/util/home-paths" } ] } diff --git a/apps/web/tests/agent-preset-selection.e2e.ts b/apps/web/tests/agent-preset-selection.e2e.ts index 6f9a7d8044..c4198ae414 100644 --- a/apps/web/tests/agent-preset-selection.e2e.ts +++ b/apps/web/tests/agent-preset-selection.e2e.ts @@ -404,8 +404,9 @@ describe('web e2e: agent-preset selection', () => { await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE) expect(snapshot).toContain('Minimal mode') expect(snapshot).toContain('button "1 subagent"') - expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode')) - expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"')) + // The preset label leads the header band; delegation navigation follows it. + expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"')) + expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"')) // Static chrome, not a control: the header can only report a composition // the host would refuse to change. expect(snapshot).not.toContain('button "Minimal mode"') diff --git a/apps/web/tests/assembled-remote.ts b/apps/web/tests/assembled-remote.ts index 5d9b7f8a14..8d7502264b 100644 --- a/apps/web/tests/assembled-remote.ts +++ b/apps/web/tests/assembled-remote.ts @@ -60,7 +60,6 @@ interface ControlBaseline { readonly type: 'baseline' readonly value: { readonly queues: Readonly> - readonly jobs: Readonly> readonly approvals: readonly unknown[] readonly questions: readonly unknown[] readonly projections: Readonly { stream.push(structuredClone(fixture.control)) }) + mock.stream('job/list', (_args, stream) => { + stream.push({ type: 'rows', jobs: [] }) + }) mock.stream('workspace/follow', (_args, stream) => { stream.push({ type: 'baseline', diff --git a/apps/web/tests/background-job-list.e2e.ts b/apps/web/tests/background-job-list.e2e.ts index f4db98483a..44427ce945 100644 --- a/apps/web/tests/background-job-list.e2e.ts +++ b/apps/web/tests/background-job-list.e2e.ts @@ -1,6 +1,6 @@ // Session-header background jobs driven by a real `ctx.jobs` entry. No model // call is involved. -import { readFile } from 'node:fs/promises' +import { readFile, writeFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' @@ -22,9 +22,12 @@ const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') const MODE = webSnapshotMode() const SEED_ID = 'background-job-list-web-e2e' -// Long enough that the running assertions never race the process exiting on -// their own; the test kills it explicitly to reach the settled state. -const COMMAND = 'sleep 45' +// A hold on a barrier file the test owns in the job's cwd: the process never +// exits on its own, so no CI stall can settle it before the scenario kills it. +// The bounded loop only caps an orphan's lifetime if the runner dies before +// `afterAll` releases the barrier. +const RELEASE = '.background-job-list.release' +const COMMAND = `for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` /** * Wait for opening a session to publish its live Agent. @@ -48,7 +51,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { let page: Page let tripwire: ReturnType let agent: Agent - let jobId: JobId beforeAll(async () => { scaffold = await launchWebScaffold({}) @@ -73,12 +75,14 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { }, 120_000) afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') await browser?.close() await scaffold?.close() }) - it('shows a running background job in the session header without a refresh', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running')) + it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => { + let phase = 'running' + onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`)) // Polling for zero would pass at t=0 before delivery and prove nothing. const trigger = page.getByRole('button', { name: '1 background job running' }) expect(await trigger.count()).toBe(0) @@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') const matched = /\bbash-\d+\b/.exec(reported) if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) - jobId = JobId(matched[0]) + const jobId = JobId(matched[0]) await trigger.waitFor({ timeout: 15_000 }) await trigger.click() @@ -101,24 +105,22 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => { await row.waitFor({ timeout: 10_000 }) await expect.poll(() => row.textContent()).toContain(COMMAND) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) - it('flips the open list to the cancelled outcome when the registry settles it', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled')) - expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested') + phase = 'settled' + expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested') const idle = page.getByRole('button', { name: '1 background job', exact: true }) await idle.waitFor({ timeout: 20_000 }) - const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) - await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) - }, 60_000) + }, 90_000) it('keeps its snapshot inventory closed', async () => { await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) diff --git a/apps/web/tests/details-session-lifecycle.e2e.ts b/apps/web/tests/details-session-lifecycle.e2e.ts index 924105c184..50088b05f7 100644 --- a/apps/web/tests/details-session-lifecycle.e2e.ts +++ b/apps/web/tests/details-session-lifecycle.e2e.ts @@ -71,15 +71,22 @@ async function columns(page: Page): Promise { getComputedStyle(element).gridTemplateColumns.split(' ').map(value => Math.round(Number.parseFloat(value)))) } +/** Select panes by visual column rather than retained-host DOM order. */ +function paneAt(column: Locator, index: number): Locator { + return column.locator(`[data-dockkit-pane][data-dockkit-column="${index}"]`) +} + /** Tab order and selection inside each docked pane, independent of generated ids. */ async function paneSnapshot(page: Page) { - return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes.map(pane => ({ - active: pane.hasAttribute('data-dockkit-pane-active'), - tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({ - title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(), - selected: tab.getAttribute('aria-selected') === 'true', - })), - }))) + return await page.locator('[data-rightbar-col] [data-dockkit-pane]').evaluateAll(panes => panes + .sort((left, right) => Number(left.getAttribute('data-dockkit-column')) - Number(right.getAttribute('data-dockkit-column'))) + .map(pane => ({ + active: pane.hasAttribute('data-dockkit-pane-active'), + tabs: [...pane.querySelectorAll('[data-dockkit-tab]')].map(tab => ({ + title: tab.querySelector('[data-dockkit-tab-title]')?.textContent?.trim(), + selected: tab.getAttribute('aria-selected') === 'true', + })), + }))) } /** Product-visible geometry, pane state, and expanded Files directories at a settled checkpoint. */ @@ -311,16 +318,15 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await Promise.allSettled(frame.getAnimations().map(animation => animation.finished)) }) await expect.poll(() => detailsTrack(page)).toBe(0) - await panel.waitFor({ state: 'hidden' }) + await panel.locator('[data-dockkit-pane]').first().waitFor({ state: 'hidden' }) } await select(original, 'LIGHTHOUSE') await open() await column.locator('[data-sidebar-right-guide-entry="files"]').click() - // The content-box panel adds its one rendered border pixel outside the - // CSS width assigned by the grid solver. + // Pane borders stay inside the grid width; the shared panel owns no border. await expect.poll(() => sidebarSnapshot(page), { timeout: 5_000 }) - .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth + 1, resizeHandleWidth: 8 }) + .toMatchObject({ mode: 'push', panelContentWidth: normalWidth, panelOuterWidth: normalWidth, resizeHandleWidth: 8 }) await expect.poll(async () => ({ filesVisible: await column.locator('[data-files-state="tree"]').isVisible(), errors: tripwire.pageErrors, @@ -330,9 +336,9 @@ describe.skipIf(MODE === 'record')('web e2e: details panel follows the current S await expect.poll(() => split.isDisabled()).toBe(false) await split.click() await expect.poll(() => panes.count()).toBe(2) - await panes.last().locator('[data-sidebar-right-guide-entry="files"]').click() - await panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() - await expect.poll(() => panes.first().locator('[data-files-state="tree"]').count()).toBe(1) + await paneAt(column, 1).locator('[data-sidebar-right-guide-entry="files"]').click() + await paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: 'Files' }).click() + await expect.poll(() => paneAt(column, 0).locator('[data-files-state="tree"]').count()).toBe(1) const retainedA = await paneSnapshot(page) expect(retainedA.map(pane => pane.tabs.map(tab => tab.title))).toEqual([['Files', 'Start'], ['Files']]) await checkpoint('A normal: two panes') diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index d78cf7b464..603569a567 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -89,8 +89,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () let outsideRoot: string | undefined let nativeRoot: string | undefined let openLog = '' - const opened = async (): Promise> => - (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' }) + const opened = async (): Promise> => + (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) @@ -102,10 +102,34 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!/usr/bin/env node const fs = require('node:fs'); -const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; -const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; +const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; +const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); `, { mode: 0o700 }) + if (process.platform === 'darwin') { + const apps = [ + { id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` }, + { id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null }, + ] + await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 }) + } + if (process.platform === 'linux') { + const data = join(nativeRoot, 'data') + await mkdir(join(data, 'applications'), { recursive: true }) + const icon = join(nativeRoot, 'icon.png') + await writeFile(icon, TINY_PNG) + await writeFile(join(data, 'applications', 'test.desktop'), `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`) + await writeFile(join(data, 'applications', 'other.desktop'), '[Desktop Entry]\nName=Other Player\n') + await writeFile(join(nativeRoot, 'gio'), `#!/usr/bin/env node +const fs = require('node:fs'); +if (process.argv[2] === 'info') process.stdout.write('standard::content-type: video/mp4'); +else if (process.argv[2] === 'mime') process.stdout.write('Default application for video/mp4: test.desktop\\nRegistered applications:\\n test.desktop\\n other.desktop\\n'); +else if (process.argv[2] === 'launch') fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n'); +else process.exit(1); +`, { mode: 0o700 }) + vi.stubEnv('XDG_DATA_HOME', data) + vi.stubEnv('XDG_DATA_DIRS', '') + } vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) } // The Open In rows carry the default-application controls; the SSH marker @@ -747,13 +771,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await headerOpen.waitFor({ timeout: 15_000 }) const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]') await emptyOpen.waitFor({ timeout: 15_000 }) + const prominent = unsupported.locator('[data-open-target="file"][data-size="large"]') + expect((await prominent.boundingBox())?.height).toBe(40) + expect((await unsupported.locator('[data-open-path]').boundingBox())?.height).toBe(24) await successShot(page, 'unsupported') sections.push([ '## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`, - `- Header control: ${await headerOpen.innerText()}`, - `- Empty-state control: ${await emptyOpen.innerText()}`, + `- Header control has no text: ${String(await headerOpen.innerText() === '')}`, + `- Empty-state control has text: ${String((await emptyOpen.innerText()).length > 0)}`, ].join('\n')) if (STUB_OPENER) { // Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu. @@ -761,19 +788,32 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + await emptyOpen.click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1) await unsupported.locator('[data-open-path-more]').click() - await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() + await page.getByRole('menuitem', { name: /^Show file location/ }).click() await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2) const gestures = await opened() - expect(gestures[0]).toEqual({ path: clip, action: 'open' }) + expect(gestures[0]?.action).toBe('open') + expect([clip, cwd]).toContain(gestures[0]?.path) expect(gestures[1]?.action).toBe('reveal') expect([clip, cwd]).toContain(gestures[1]?.path) + if (STUB_OPENER) { + await expect.poll(() => headerOpen.locator('img').count()).toBe(1) + await prominent.getByRole('button', { name: 'More ways to open' }).click() + await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications.expected.md'), await page.getByRole('menu').ariaSnapshot(), MODE) + await page.getByRole('menuitem', { name: 'Other Player', exact: true }).click() + await expect.poll(async () => (await opened()).length).toBe(3) + expect((await opened())[2]).toEqual({ path: clip, action: 'application' }) + await headerOpen.click() + await expect.poll(async () => (await opened()).length).toBe(4) + expect((await opened())[3]).toEqual({ path: clip, action: 'open' }) + } // Gesture facts stay out of the golden: the stub does not run on Windows. expect(await page.getByRole('alert').count()).toBe(0) } expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE) - await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml']) + await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml']) }) }) diff --git a/apps/web/tests/expected/agent-preset-selection/header.expected.md b/apps/web/tests/expected/agent-preset-selection/header.expected.md index 32b0ea6a2a..2b87066124 100644 --- a/apps/web/tests/expected/agent-preset-selection/header.expected.md +++ b/apps/web/tests/expected/agent-preset-selection/header.expected.md @@ -1,6 +1,5 @@ -- navigation "Session hierarchy": - - text: Seeded turn / - - button "1 subagent" +- navigation "Session hierarchy": Seeded turn - text: Minimal mode +- button "1 subagent" - button "More actions" - button "Open right sidebar" diff --git a/apps/web/tests/expected/input-plugin-extensions.expected.md b/apps/web/tests/expected/input-plugin-extensions.expected.md new file mode 100644 index 0000000000..512b4d0884 --- /dev/null +++ b/apps/web/tests/expected/input-plugin-extensions.expected.md @@ -0,0 +1,5 @@ +- textbox "Describe what you want to build, / commands, @ files or sessions": + - paragraph: Draft +- button "Cancel activity" +- button "Insert result" +- button "Send message" diff --git a/apps/web/tests/fixtures/assembled-remote.fixture.json b/apps/web/tests/fixtures/assembled-remote.fixture.json index 7a414d0a07..9d0d916a99 100644 --- a/apps/web/tests/fixtures/assembled-remote.fixture.json +++ b/apps/web/tests/fixtures/assembled-remote.fixture.json @@ -265,11 +265,6 @@ "fx-beta": [], "fx-gamma": [] }, - "jobs": { - "fx-alpha": [], - "fx-beta": [], - "fx-gamma": [] - }, "approvals": [], "questions": [], "projections": { diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js b/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js new file mode 100644 index 0000000000..6722f15db8 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/client.js @@ -0,0 +1,46 @@ +/** A published-protocol consumer of composer activities and bundle activation guidance. */ +window.__ModuleLoader__.load({ + id: '@fixture/input-extension', + factory(require) { + const React = require('react') + const { Button, Modal, IconApiOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives') + const h = React.createElement + return { + inject: ['slots', 'locale'], + apply(ctx) { + const labels = { + start: 'Input activity', cancel: 'Cancel activity', insert: 'Insert result', stale: 'Draft changed', + setup: 'Input plugin setup', details: 'Open setup', later: 'Later', config: 'Input plugin configuration', + } + ctx.effect(() => ctx.locale.register('fixtureInput', { en: labels, zh: labels })) + ctx.slots.inject('conversation.input.activity', () => ctx.slots.register({ + name: 'conversation.input.activity', locale: 'fixtureInput', + }, ({ inputActions, onActiveChange, locked, t }) => { + const [span, setSpan] = React.useState(null) + const [stale, setStale] = React.useState(false) + React.useEffect(() => () => onActiveChange(false), [onActiveChange]) + const close = () => { setSpan(null); setStale(false); onActiveChange(false) } + return span === null + ? h(Button, { disabled: locked, 'aria-label': t('start'), style: { width: 32, height: 32, padding: 0 }, onClick: () => { + setSpan(inputActions.captureInsertion()); onActiveChange(true) + } }, h(IconApiOutlineRegular, { size: 16 })) + : h('div', null, + h(Button, { onClick: close }, t('cancel')), + h(Button, { onClick: () => { + if (inputActions.insertText(' from plugin', span)) close() + else setStale(true) + } }, t('insert')), + stale ? h('span', { role: 'status' }, t('stale')) : null) + })) + ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({ + name: 'plugins.bundle.config', key: '@fixture/input-extension', locale: 'fixtureInput', + }, ({ t }) => h('p', null, t('config')))) + ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({ + name: 'plugins.bundle.activation', key: '@fixture/input-extension', locale: 'fixtureInput', + }, ({ onDismiss, onOpenDetails, t }) => h(Modal, { + open: true, title: t('setup'), closeLabel: t('later'), onClose: onDismiss, + }, h(Button, { onClick: onDismiss }, t('later')), h(Button, { onClick: onOpenDetails }, t('details'))))) + }, + } + }, +}) diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml b/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml new file mode 100644 index 0000000000..b31d3a8fb7 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/cordis.patch.yml @@ -0,0 +1,3 @@ +- insert: + - id: fixture-input-extension + name: '@fixture/input-extension' diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js b/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js new file mode 100644 index 0000000000..7f53b13ca2 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/index.js @@ -0,0 +1,2 @@ +/** Host companion for the optional input extension fixture. */ +export function apply() {} diff --git a/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json b/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json new file mode 100644 index 0000000000..5590f85115 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-input-extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "@fixture/input-extension", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "index.js", + "exports": { ".": "./index.js", "./client": "./client.js" }, + "dsh": { + "bundle": { "patch": "./cordis.patch.yml" }, + "client": { "platform": "web" } + } +} diff --git a/apps/web/tests/image-display.expected.e2e.ts b/apps/web/tests/image-display.expected.e2e.ts index adbb742d0b..e3a98dc8f5 100644 --- a/apps/web/tests/image-display.expected.e2e.ts +++ b/apps/web/tests/image-display.expected.e2e.ts @@ -171,7 +171,10 @@ it('accepts a whole-page drop under the limits-labeled overlay and refuses an ov // desc line carries the projected limits — copy that can only render after // the imageLimits projection crossed the RemoteMock transport. const image = new File([new Uint8Array([137, 80, 78, 71])], 'dropped.png', { type: 'image/png' }) - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { + types: ['Files'], files: [image], dropEffect: 'none', + items: [{ kind: 'file', getAsFile: () => image, webkitGetAsEntry: () => ({ isDirectory: false }) }], + } fireEvent.dragEnter(document.body, { dataTransfer }) const overlay = await screen.findByRole('status') expect(overlay.textContent).toContain('Drag files or images here to add them') diff --git a/apps/web/tests/input-plugin-extensions.e2e.ts b/apps/web/tests/input-plugin-extensions.e2e.ts new file mode 100644 index 0000000000..cd8548c6f3 --- /dev/null +++ b/apps/web/tests/input-plugin-extensions.e2e.ts @@ -0,0 +1,82 @@ +/** Optional input contributions mount through the real Loader and preserve editor ownership. */ +import { fileURLToPath } from 'node:url' +import { chromium, type Browser } from 'playwright' +import { expect, it, onTestFinished } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage } from './support.ts' + +const bundle = fileURLToPath(new URL('./fixtures/plugins/fixture-input-extension', import.meta.url)) +const expected = fileURLToPath(new URL('./expected/input-plugin-extensions.expected.md', import.meta.url)) + +async function fixture(enabled: boolean) { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + onTestFinished(async () => { + try { await resources.browser?.close() } finally { await resources.scaffold?.close() } + }) + const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled }] } }) + resources.scaffold = scaffold + const browser = await chromium.launch() + resources.browser = browser + const page = await newEnglishPage(browser), tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl) + return { scaffold, page, tripwire } +} + +it('expands an input activity and inserts one undoable result without overwriting subsequent edits', async () => { + const { scaffold, page, tripwire } = await fixture(true) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + const input = page.locator('[data-composer-input]') + const start = page.getByRole('button', { name: 'Input activity', exact: true }) + const model = page.getByRole('button', { name: /^Select model, current/ }) + const send = page.getByRole('button', { name: 'Send message', exact: true }) + await input.fill('Draft') + await input.press('End') + for (const width of [1280, 420]) { + await page.setViewportSize({ width, height: 900 }) + await expect.poll(async () => { + const activityBox = await start.boundingBox(), modelBox = await model.boundingBox(), sendBox = await send.boundingBox() + return activityBox !== null && modelBox !== null && sendBox !== null + && activityBox.x >= modelBox.x + modelBox.width && activityBox.x + activityBox.width <= sendBox.x + }).toBe(true) + } + await start.click() + expect(await model.isVisible()).toBe(false) + expect(await send.isVisible()).toBe(true) + await compareOrRefreshGolden(expected, + await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode()) + await page.getByRole('button', { name: 'Insert result', exact: true }).click() + await expect.poll(() => input.innerText()).toBe('Draft from plugin') + await input.press('ControlOrMeta+z') + await expect.poll(() => input.innerText()).toBe('Draft') + await start.click() + await input.fill('Newer draft') + await page.getByRole('button', { name: 'Insert result', exact: true }).click() + await page.getByRole('status').filter({ hasText: 'Draft changed' }).waitFor() + expect(await input.innerText()).toBe('Newer draft') + await page.getByRole('button', { name: 'Cancel activity', exact: true }).click() + expect(await model.isVisible()).toBe(true) + expect(tripwire.pageErrors).toEqual([]) +}) + +it('offers bundle guidance only after explicit activation and navigates to its configuration', async () => { + const { page, tripwire } = await fixture(false) + await page.getByRole('button', { name: 'Plugins', exact: true }).click() + const toggle = page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true }) + await toggle.waitFor() + const dialog = page.getByRole('dialog', { name: 'Input plugin setup', exact: true }) + expect(await dialog.count()).toBe(0) + await toggle.click() + await dialog.waitFor() + await dialog.getByRole('button', { name: 'Later', exact: true }).last().click() + await dialog.waitFor({ state: 'hidden' }) + expect(await toggle.getAttribute('aria-checked')).toBe('true') + await toggle.click() + await page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true, checked: false }).waitFor() + await toggle.click() + await dialog.getByRole('button', { name: 'Open setup', exact: true }).click() + await page.getByText('Input plugin configuration', { exact: true }).waitFor() + expect(await dialog.count()).toBe(0) + expect(tripwire.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/live-job-stream.e2e.ts b/apps/web/tests/live-job-stream.e2e.ts new file mode 100644 index 0000000000..9aa70ec2bd --- /dev/null +++ b/apps/web/tests/live-job-stream.e2e.ts @@ -0,0 +1,136 @@ +// Session-header job list driven by a real background bash run: the job row +// arrives over the job roster stream, expanding it opens the output +// observation stream, and the panel shows the process's real output while it +// is still running. No model call is involved. +import { readFile, writeFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { ToolCallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { JobId } from '@deepseek-ai/dsh-jobs' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-job-stream', import.meta.url)) +const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'live-job-stream-web-e2e' +// One fixed line of early output, then a hold on a barrier file the test owns +// in the job's cwd: the process never exits on its own, so no CI stall can +// settle it before the scenario kills it. The bounded loop only caps an +// orphan's lifetime if the runner dies before `afterAll` releases the barrier. +const RELEASE = '.live-job-stream.release' +const COMMAND = `printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done` + +/** + * Wait for opening a session to publish its live Agent. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: live job stream', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // job owner must be that exact live instance, never a second one. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '') + await browser?.close() + await scaffold?.close() + }) + + it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => { + let phase = 'streaming' + onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`)) + const trigger = page.getByRole('button', { name: '1 background job running' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: ToolCallId('live-job-stream-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no job id: ${reported}`) + const jobId = JobId(matched[0]) + + // The job row reaches the header over the job roster stream. + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + + // Expanding the row opens the output observation stream; the panel then renders + // the command's real stdout while the process is still running. + const expand = page.getByRole('button', { name: /Show live output/ }) + await expand.waitFor({ timeout: 10_000 }) + await expand.click() + await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 }) + + const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + + phase = 'settled' + expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested') + + // Settlement arrives on the observation stream itself, so the open panel + // flips without any further interaction; the roster trigger follows. + const idle = page.getByRole('button', { name: '1 background job', exact: true }) + await idle.waitFor({ timeout: 20_000 }) + await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 }) + + const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md']) + }) +}) diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts index 7323495984..ff57285d88 100644 --- a/apps/web/tests/open-in-app-ssh.e2e.ts +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -60,8 +60,7 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { ]) expect(response.status()).toBe(200) expect(await response.json()).toEqual({ apps: [] }) - expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) - expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.locator('[data-open-target="directory"]').count()).toBe(0) expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/present-svg.e2e.ts b/apps/web/tests/present-svg.e2e.ts index 7c856df6f9..461b71e0e3 100644 --- a/apps/web/tests/present-svg.e2e.ts +++ b/apps/web/tests/present-svg.e2e.ts @@ -128,7 +128,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => { it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => { await assertFinalWorkspaceSnapshot(DIR, cwd) - await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true) + expect(await page.locator('[data-presented-file] [data-open-target]').count()).toBe(0) // Delivery owns the transcript; navigation and composer chrome have separate scenarios. const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 1c4c5fe44f..36d237857a 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -6,6 +6,7 @@ import { chromium, type Browser, type Page } from 'playwright' import { unzipSync, strFromU8 } from 'fflate' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { tmpdir, release } from 'node:os' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-tool-present/types' import { @@ -53,10 +54,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) await mkdir(DIR, { recursive: true }) scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)), agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true, ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), }) + // File associations belong to the desktop rather than the recorded Session. + const controller = scaffold.ctx.get('sessionController') + if (controller === undefined) throw new Error('present requires Session Controller') + const nativeQuery: unknown = Reflect.get(controller, 'fileApplications') + if (typeof nativeQuery !== 'function') throw new Error('present requires native association discovery') + Reflect.set(controller, 'fileApplications', async () => [{ id: 'test-editor', name: 'Test Editor', default: true, icon: null }]) + scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'present: native association fixture') disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true }) scaffold.ctx.on('session/event', (_session, event) => { events.push(event) }) browser = await chromium.launch() @@ -121,8 +130,20 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const row = page.locator('[data-presented-files-row]') await row.waitFor() - expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2) + await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2) expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt') + const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover() + await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor() + expect(await page.getByRole('tooltip').evaluate((tooltip) => { + const rect = tooltip.getBoundingClientRect() + const previous = tooltip.style.pointerEvents + tooltip.style.pointerEvents = 'auto' + const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip + tooltip.style.pointerEvents = previous + return tooltip.parentElement === document.body && visible + })).toBe(true) + await page.mouse.move(0, 0) const beforePreview = (await opened()).length const column = page.locator('[data-rightbar-col]') for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) { @@ -139,19 +160,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(downloads).toEqual([]) await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click() const beforeReveal = (await opened()).length - await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More ways to open', exact: true }).click() const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST') - await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click() + await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click() expect((await revealResponse).status()).toBe(204) - expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true }) - .evaluate(button => button === document.activeElement)).toBe(true) await expect.poll(opened).toHaveLength(beforeReveal + 1) expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) }) for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) { const count = (await opened()).length const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') - await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await response).status()).toBe(204) await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null) expect(await opened()).toHaveLength(count + 1) @@ -174,6 +192,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con ]) expect(exported).not.toContain('EDITED_REPORT') if (MODE !== 'record') { + await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0) const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE) await page.locator('[data-turn-process]').click() @@ -215,9 +234,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con .find(element => element.textContent === 'report.txt'), 'report title', ) - const description = requiredElement(report.querySelector('span[role="status"]'), 'report status') + const description = requiredElement(report.querySelector('[data-presented-description]'), 'report description') const open = requiredElement( - report.querySelector('button[aria-label="Open report.txt in sidebar"]'), + report.querySelector('[data-open-target] button'), 'report open action', ) const icon = requiredElement(report.querySelector('svg'), 'report icon') @@ -250,7 +269,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(geometry.iconWidth).toBe('20') expect(geometry.titleFontSize).toBe('13px') expect(geometry.descriptionFontSize).toBe('10px') - expect(geometry.openFontSize).toBe('12px') + expect(geometry.openFontSize).toBe('11px') await page.setViewportSize({ width: 480, height: 900 }) const row = page.locator('[data-presented-files-row]') await row.scrollIntoViewIfNeeded() @@ -263,9 +282,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con } const beforeDelete = (await opened()).length await unlink(join(cwd, 'report.txt')) - const missing = page.waitForResponse(response => response.url().includes('/api/present.open?')) - await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click() - await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click() + const missing = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST') + await page.locator('[data-presented-file]').filter({ hasText: 'report.txt' }) + .getByRole('button', { name: 'Open in Test Editor', exact: true }).click() expect((await missing).status()).toBe(404) await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor() expect(await opened()).toHaveLength(beforeDelete) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 7cf05dd30a..90f6387c7d 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -305,8 +305,8 @@ export interface LaunchOptions { developerTools?: boolean /** Enable the real Open In rows with deterministic launch-environment facts. */ openInAppEnvironment?: LaunchEnvironmentSnapshot - /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ - compareReplaySession?: boolean + /** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */ + compareReplaySession?: boolean | 'read-only' /** * Optional product overlay applied after the shipped Web surface and before * the scaffold's hermetic test patches, matching the launcher's `--patch` @@ -965,7 +965,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { ], openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), }) + // Application registrations belong to the host desktop, not to the recorded Session. + const controller = scaffold.ctx.get('sessionController') + if (controller === undefined) throw new Error('seeded-history requires Session Controller') + const nativeQuery: unknown = Reflect.get(controller, 'fileApplications') + if (typeof nativeQuery !== 'function') throw new Error('seeded-history requires the native file-association adapter') + Reflect.set(controller, 'fileApplications', async () => []) + scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'seeded-history: native association fixture') // Composer recording uses a child workspace; seedSession owns the scaffold root. const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd await mkdir(sessionCwd, { recursive: true }) @@ -518,7 +525,8 @@ describe('web e2e: seeded history renders through cold resume', () => { await expect.poll(() => path.textContent()).toBe(absolutePath) expect(await path.getAttribute('title')).toBe(absolutePath) await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n') - await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 }) + await column.getByRole('button', { name: 'Show file location', exact: true }).waitFor({ timeout: 5_000 }) + await expect.poll(() => column.locator('[data-open-path-open]').isEnabled()).toBe(true) const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd) await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE) } finally { diff --git a/apps/web/tests/sidebar-right.e2e.ts b/apps/web/tests/sidebar-right.e2e.ts index 90571db3f4..af37b968e1 100644 --- a/apps/web/tests/sidebar-right.e2e.ts +++ b/apps/web/tests/sidebar-right.e2e.ts @@ -85,17 +85,24 @@ async function pointIn(locator: Locator, fx: number, fy: number): Promise<{ x: n return { x: box.x + box.width * fx, y: box.y + box.height * fy } } -/** Pause the panel's next real transform transition after observing its initial frame geometry. */ +/** Select panes by CSS column; retained tab hosts keep their independent DOM order. */ +function paneAt(column: Locator, index: number): Locator { + return column.locator(`[data-dockkit-pane][data-dockkit-column="${index}"]`) +} + +/** Pause a docked host's next real transform transition at its midpoint. */ async function holdPanelSlide(panel: Locator) { return await panel.evaluateHandle((node) => { const controller = new AbortController() const state = { animation: null as Animation | null, columnsAtStart: '', dispose: () => { controller.abort() } } node.addEventListener('transitionrun', (event) => { - if (event.target !== node || (event as TransitionEvent).propertyName !== 'transform') return + const target = event.target + if (!(target instanceof Element) || !target.matches('[data-dockkit-host="dock"]') + || (event as TransitionEvent).propertyName !== 'transform') return const frame = node.closest('[style*="grid-template-columns"]') if (frame === null) throw new Error('panel frame is unavailable') state.columnsAtStart = getComputedStyle(frame).gridTemplateColumns - const slide = node.getAnimations().find(animation => + const slide = target.getAnimations().find(animation => 'transitionProperty' in animation && animation.transitionProperty === 'transform') if (slide === undefined || slide.effect === null) throw new Error('panel transform transition is unavailable') slide.pause() @@ -494,7 +501,9 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(() => panel.boundingBox()).toEqual({ x: 0, y: 0, ...viewport }) await column.locator('[data-sidebar-right-toggle]').click() await Promise.all([ - panel.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }), + panel.evaluate(async (node) => { + await Promise.allSettled(node.getAnimations({ subtree: true }).map(animation => animation.finished)) + }), frame.evaluate(async (node) => { await Promise.allSettled(node.getAnimations().map(animation => animation.finished)) }), ]) const closedColumns = (await geometry()).columns @@ -507,7 +516,7 @@ describe('web e2e: shipped right Sidebar', () => { try { await expandOf(page).click() await expect.poll(() => held.evaluate(state => state.animation?.playState)).toBe('paused') - const entering = await panel.boundingBox() + const entering = await paneAt(column, 0).boundingBox() if (entering === null) throw new Error('entering panel is not rendered') expect(entering.x).toBeGreaterThan(0) expect(entering.x).toBeLessThan(viewport.width) @@ -525,7 +534,7 @@ describe('web e2e: shipped right Sidebar', () => { await column.locator('[data-sidebar-right-toggle]').click() await expect.poll(() => exit.evaluate(state => state.animation?.playState)).toBe('paused') expect(await exit.evaluate(state => state.columnsAtStart)).toBe(closedColumns) - const leaving = await panel.boundingBox() + const leaving = await paneAt(column, 0).boundingBox() if (leaving === null) throw new Error('leaving panel is not rendered') expect(leaving.x).toBeGreaterThan(0) expect(leaving.x).toBeLessThan(viewport.width) @@ -719,7 +728,7 @@ describe('web e2e: shipped right Sidebar', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-content')) const column = page.locator('[data-rightbar-col]') const panes = column.locator('[data-dockkit-pane]') - const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]') + const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') // Observation before action: does the read ever leave the browser? The // assertion states the healthy answer so a failure prints the real one. @@ -751,7 +760,7 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) // Opening the same content again focuses rather than duplicating. - await panes.first().locator('[data-dockkit-tab]').first().click() + await paneAt(column, 0).locator('[data-dockkit-tab]').first().click() await chip.click() await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) @@ -776,20 +785,20 @@ describe('web e2e: shipped right Sidebar', () => { expect(await folders.count()).toBe(await page.locator('[data-changed-files]').getByRole('button', { name: /folder/i }).count()) // Split, then dock-drag: the kit's gestures drive the store's actions. - await panes.first().locator('[data-dockkit-split-button]').click() + await paneAt(column, 0).locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) await dragTo( page, column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first(), - await pointIn(panes.nth(1), 0.5, 0.94), + await pointIn(paneAt(column, 1), 0.5, 0.94), ) await expect.poll(async () => await panes.count()).toBe(2) - const splitGuide = panes.nth(1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + const splitGuide = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) expect(await splitGuide.locator('[data-dockkit-tab-close]').count()).toBe(1) - await dragTo(page, splitGuide, await pointIn(panes.first(), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toEqual([SAMPLE_NAME]) - const movedGuide = panes.first().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await dragTo(page, splitGuide, await pointIn(paneAt(column, 0), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(paneAt(column, 1))).toEqual([SAMPLE_NAME]) + const movedGuide = paneAt(column, 0).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await movedGuide.hover() await movedGuide.locator('[data-dockkit-tab-close]').click() @@ -798,12 +807,15 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await filePane.locator('[data-dockkit-add-tab]').count()).toBe(1) expect(await column.locator('[data-dockkit-add-tab]').count()).toBe(2) - // Floating leaves the column entirely, and survives collapsing it. The - // pane the tab was alone in goes with it: an emptied pane never stays. + // A float stays in the DOM column but draws outside it and survives collapse. const tab = column.locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }).first() await floatByDrag(page, tab) await expect.poll(async () => await floats.count()).toBe(1) - expect(await column.locator('[data-dockkit-float]').count()).toBe(0) + expect(await column.locator('[data-dockkit-float]').count()).toBe(1) + const floatBox = await floats.first().boundingBox() + const columnBox = await column.boundingBox() + if (floatBox === null || columnBox === null) throw new Error('float or column is not rendered') + expect(floatBox.x).toBeLessThan(columnBox.x) await expect.poll(async () => await panes.count()).toBe(1) await shot(page, '04-split-and-float') @@ -891,7 +903,7 @@ describe('web e2e: shipped right Sidebar', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-right-gestures')) const column = await resetSidebar(page) const panes = column.locator('[data-dockkit-pane]') - const floats = page.locator('[data-sidebar-right-float-host] [data-dockkit-float]') + const floats = page.locator('[data-sidebar-right-session]:not([hidden]) [data-dockkit-float]') // Chromium cancels pointer capture if a render replaces the pressed // element; jsdom cannot establish that the whole gesture survives. @@ -899,7 +911,7 @@ describe('web e2e: shipped right Sidebar', () => { // 1. Reorder inside one strip: drop the last tab left of its neighbours. // The first pane needs two tabs for this — and for the move below to // leave it standing, since a pane emptied by a move is dropped. - const first = panes.first() + const first = paneAt(column, 0) const strip = first.locator('[data-dockkit-strip]') await proseChip(page).click() await expect.poll(async () => await tabTitles(first)).toEqual(['Files', SAMPLE_NAME]) @@ -918,8 +930,8 @@ describe('web e2e: shipped right Sidebar', () => { } const moving = first.locator('[data-dockkit-tab]').first() const title = await moving.locator('[data-dockkit-tab-title]').innerText() - await dragTo(page, moving, await pointIn(panes.nth(1), 0.5, 0.5)) - await expect.poll(async () => await tabTitles(panes.nth(1))).toContain(title) + await dragTo(page, moving, await pointIn(paneAt(column, 1), 0.5, 0.5)) + await expect.poll(async () => await tabTitles(paneAt(column, 1))).toContain(title) const splitButtons = column.locator('[data-dockkit-split-button]') await expect.poll(async () => await splitButtons.count()).toBe(0) @@ -928,14 +940,14 @@ describe('web e2e: shipped right Sidebar', () => { await expect.poll(async () => await splitButtons.count()).toBe(0) const outer = column.locator('[data-dockkit-divider]').first() - const before = await width(panes.last()) + const before = await width(paneAt(column, 1)) const grip = await centre(outer) await dragElement(page, outer, { x: grip.x - 100, y: grip.y }) - await expect.poll(async () => await width(panes.last())).toBeGreaterThan(before) + await expect.poll(async () => await width(paneAt(column, 1))).toBeGreaterThan(before) await dragElement(page, outer, { x: 0, y: grip.y }) const ratio = async (): Promise => { - const left = await width(panes.first()) - const right = await width(panes.last()) + const left = await width(paneAt(column, 0)) + const right = await width(paneAt(column, 1)) return left / (left + right) } await expect.poll(ratio).toBeCloseTo(0.2, 2) @@ -947,7 +959,7 @@ describe('web e2e: shipped right Sidebar', () => { expect(await splitButtons.count()).toBe(0) // 5. The split's guide and the document float while Files stays docked. - const floatOne = panes.last().locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) + const floatOne = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: SAMPLE_NAME }) await floatByDrag(page, floatOne) await expect.poll(async () => await floats.count()).toBe(1) const box = await floats.first().boundingBox() @@ -955,8 +967,8 @@ describe('web e2e: shipped right Sidebar', () => { await dragElement(page, floats.first().locator('[data-dockkit-float-grip]'), { x: box.x + 140, y: box.y + 90 }) await expect.poll(async () => (await floats.first().boundingBox())?.x ?? box.x).not.toBe(box.x) - await expect.poll(async () => await tabTitles(panes.last())).toEqual(['Start']) - const second = panes.last().locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) + await expect.poll(async () => await tabTitles(paneAt(column, 1))).toEqual(['Start']) + const second = paneAt(column, 1).locator('[data-dockkit-tab]').filter({ hasText: 'Start' }) await floatByDrag(page, second) await expect.poll(async () => await floats.count()).toBe(2) @@ -976,14 +988,14 @@ describe('web e2e: shipped right Sidebar', () => { const panes = column.locator('[data-dockkit-pane]') expect(await column.locator('[data-dockkit-tab-close]').count()).toBe(1) await proseChip(page).click() - await expect.poll(async () => await tabTitles(panes.first())).toEqual(['Files', SAMPLE_NAME]) - await panes.first().locator('[data-dockkit-split-button]').click() + await expect.poll(async () => await tabTitles(paneAt(column, 0))).toEqual(['Files', SAMPLE_NAME]) + await paneAt(column, 0).locator('[data-dockkit-split-button]').click() await expect.poll(async () => await panes.count()).toBe(2) // Closing a pane's last tab drops the pane: there is no separate // "close pane" gesture, and none is needed. - await panes.nth(1).locator('[data-dockkit-tab]').first().hover() - await panes.nth(1).locator('[data-dockkit-tab-close]').first().click() + await paneAt(column, 1).locator('[data-dockkit-tab]').first().hover() + await paneAt(column, 1).locator('[data-dockkit-tab-close]').first().click() await expect.poll(async () => await panes.count()).toBe(1) await expect.poll(async () => await tabTitles(column)).toEqual(['Files', SAMPLE_NAME]) diff --git a/apps/web/tests/skill-tool-row.e2e.ts b/apps/web/tests/skill-tool-row.e2e.ts index 628803ad48..1b6a9e7f10 100644 --- a/apps/web/tests/skill-tool-row.e2e.ts +++ b/apps/web/tests/skill-tool-row.e2e.ts @@ -8,11 +8,12 @@ import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, seedSession, selectedSessionFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { expandOwningTurnProcess, newEnglishPage, saveFailureShot } from './support.ts' -const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v3.jsonl', import.meta.url)) +// Any generation path names the role; the owner's highest committed generation replays. +const FIXTURE = fileURLToPath(new URL('../../../snapshots/session/skill-load/session.v4.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row', import.meta.url)) const UI_EXPECTED = fileURLToPath(new URL('../../../snapshots/web/skill-tool-row/ui.expected.md', import.meta.url)) const MODE = webSnapshotMode() @@ -26,7 +27,7 @@ describe.skipIf(MODE === 'record')('web e2e: dedicated Skill tool row', () => { let tripwire: ReturnType beforeAll(async () => { - const fixture = await readFile(FIXTURE, 'utf8') + const fixture = await readFile(await selectedSessionFixture(FIXTURE), 'utf8') expect(fixtureUserPrompts(fixture)).toEqual([PROMPT]) scaffold = await launchWebScaffold({}) await seedSession(scaffold, fixture, SEED_ID) diff --git a/apps/web/tests/snapshots/live-job-stream/settled.expected.md b/apps/web/tests/snapshots/live-job-stream/settled.expected.md new file mode 100644 index 0000000000..ada83b7f3e --- /dev/null +++ b/apps/web/tests/snapshots/live-job-stream/settled.expected.md @@ -0,0 +1,6 @@ +- list "Background jobs": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM; web e2e cancellation {{duration}}" + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tests/snapshots/live-job-stream/streaming.expected.md b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md new file mode 100644 index 0000000000..566aea30c0 --- /dev/null +++ b/apps/web/tests/snapshots/live-job-stream/streaming.expected.md @@ -0,0 +1,6 @@ +- list "Background jobs": + - listitem: + - button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running + - text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done + - button "Copy" + - text: streamed-marker-line diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 719f04f24d..30e2d212b5 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -51,6 +51,7 @@ "tests/office-fixture.ts", "tests/sidebar-browser.e2e.ts", "tests/plugin-config.e2e.ts", + "tests/input-plugin-extensions.e2e.ts", "tests/plugin-manager.e2e.ts", "tests/plugin-install-cancel.e2e.ts", "tests/plugin-install-approve.e2e.ts", @@ -135,6 +136,7 @@ "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", "tests/background-job-list.e2e.ts", + "tests/live-job-stream.e2e.ts", "tests/auto-review-denial.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index a63b916549..a31bcf99da 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -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 docs/architecture.md -architecture.md: 9b0daeef5ab95335d650ea987cd4588f28d3f870 -architecture.zh.md: 0d8581063d43723a8fdbf4190ec468f6de103a27 +architecture.md: 514bd52cfa8f50fe8cf73c391094d34942b3cca3 +architecture.zh.md: 848fa1b3810403e4a3906ac491c8552052e4ee4e diff --git a/docs/architecture.md b/docs/architecture.md index 9b0daeef5a..514bd52cfa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -146,7 +146,7 @@ New behavior attaches to a documented extension point. Changing the loop itself | Add shell execution | register a `ctx.shell` backend; the local one spawns through `ctx.subprocess` | | Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` | | Add a human command | register on `ctx.commands`; it dispatches without a model turn | -| Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it | +| Manage background jobs | register on `ctx.jobs`; `job_*` tools read or stop jobs | | Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter | | Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events | | Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 0d8581063d..848fa1b381 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -150,7 +150,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统 | 添加 shell 执行 | 注册 `ctx.shell` 后端;本地后端通过 `ctx.subprocess` spawn 进程 | | 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` | | 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 | -| 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 | +| 管理后台任务 | 在 `ctx.jobs` 上注册;`job_*` 工具读取或停止任务 | | 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 | | 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 | | 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 8c28ee4357..b5c8b49223 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -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 docs/capability-seams.md -capability-seams.md: edb8cd688768ec49ee3699c3775409a66975129d -capability-seams.zh.md: b06d9378616669407b79ffa1da205924ff6ad757 +capability-seams.md: eea821874c1932d7daa5f0a2aec3f18653757142 +capability-seams.zh.md: c7794eae7bfd6456b7878a5b98a27fdec9f2adab diff --git a/docs/capability-seams.md b/docs/capability-seams.md index edb8cd6887..eea821874c 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -67,6 +67,8 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] @@ -259,6 +261,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -432,8 +435,10 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -555,6 +560,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | Streams one background job's observation record over the generated Remote namespace; the roster stays on the session control stream. | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. | @@ -608,7 +614,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. | | `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index b06d937861..c7794eae7b 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -69,6 +69,8 @@ flowchart LR svc_sessionController["ctx.sessionController
Host Session Remote controller"] svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"] svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"] + pkg_api_job_controller["api-job-controller"] + svc_jobController["ctx.jobController
Host job Remote controller"] pkg_api_settings_controller["api-settings-controller"] svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"] svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"] @@ -261,6 +263,7 @@ flowchart LR pkg_agent_loop --> svc_agentLoop pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway + pkg_api_job_controller --> svc_jobController pkg_api_session_controller --> svc_sessionController pkg_api_session_controller --> svc_sessionFileReferences pkg_api_session_controller --> svc_sessionSkillCatalog @@ -434,8 +437,10 @@ flowchart LR svc_invariants --> pkg_agent_loop svc_invariants --> pkg_scope svc_invariants --> pkg_session + svc_jobs --> pkg_api_job_controller svc_jobs --> pkg_tool_bash svc_jobs --> pkg_tool_jobs + svc_jobs --> pkg_tool_pwsh svc_jobs --> pkg_tool_subagent svc_jobs --> pkg_tool_terminal svc_llm --> pkg_agent_loop @@ -557,6 +562,7 @@ flowchart LR | `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 | | `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 | | `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 | +| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | 经生成的 Remote namespace 流式发送一个后台任务的观测 record;名册仍在会话控制流上。 | | `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 | | `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 | | `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 | @@ -610,7 +616,7 @@ flowchart LR | `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 | | `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 | | `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 | -| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | +| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 | | `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 578d94c8c8..2aa6cd3874 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 6bd14a5aa194de15463c0d63639c0f5095995f99 -config-catalog.zh.md: ed0a6cbcc7b28e3875b50ba90e427ca06e90a8dc +config-catalog.md: bb95bd3794b0be4fd7688f77c2ac77efd4249ecf +config-catalog.zh.md: b1297bf254db369d063a39395d1678f91f807550 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6bd14a5aa1..bb95bd3794 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -201,6 +201,24 @@ export interface Config { Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) + + +## `@deepseek-ai/dsh-api-job-controller` + +Requires: `jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + ## `@deepseek-ai/dsh-api-session-controller` @@ -215,7 +233,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) @@ -1160,10 +1178,20 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** + * Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. + * Settlement keeps every byte the model cursor has not consumed on top of + * this cap; the first terminal model read then trims to it. + */ + settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +Source: [`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -2938,7 +2966,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:247`](../packages/core/system-prompt/src/index.ts) @@ -3173,14 +3201,14 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ export type CompletionDelivery = 'quiet' | 'wakeup' ``` -Source: [`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts) +Source: [`packages/jobs/tool-jobs/src/index.ts:52`](../packages/jobs/tool-jobs/src/index.ts) @@ -3232,7 +3260,7 @@ export interface Config { } ``` -Source: [`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts) +Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3417,7 +3445,7 @@ export interface Config { } ``` -Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) @@ -3903,7 +3931,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-terminal` ([`packages/terminal/terminal/src/index.ts`](../packages/terminal/terminal/src/index.ts)) - `@deepseek-ai/dsh-tool-ask-user` — requires `tools` · `userQuestions` ([`packages/interaction/tool-ask-user/src/index.ts`](../packages/interaction/tool-ask-user/src/index.ts)) - `@deepseek-ai/dsh-tool-call-timeout-policy` — requires `tools` ([`packages/guard/timeout-policy/src/index.ts`](../packages/guard/timeout-policy/src/index.ts)) -- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `systemPrompt` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) +- `@deepseek-ai/dsh-tool-cordis` — requires `tools` · `cordisInspect` ([`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/tool-cordis/src/index.ts)) - `@deepseek-ai/dsh-tool-subagent-control` — requires `tools` · `subagents` ([`packages/subagent/tool-subagent-control/src/index.ts`](../packages/subagent/tool-subagent-control/src/index.ts)) - `@deepseek-ai/dsh-user-questions` ([`packages/interaction/user-questions/src/index.ts`](../packages/interaction/user-questions/src/index.ts)) - `@deepseek-ai/dsh-webhook` — requires `agents` · `agentDefaultModel` · `agentPresets` · `permissionPresets` · `sessionTitle` · `workspaceRegistry` ([`packages/webhook/webhook/src/index.ts`](../packages/webhook/webhook/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ed0a6cbcc7..b1297bf254 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -203,6 +203,26 @@ export interface Config { 来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts) + + +## `@deepseek-ai/dsh-api-job-controller` + +需要:`jobs` · `typert` + +```ts config-catalog +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} +``` + +来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts) + + + ## `@deepseek-ai/dsh-api-session-controller` @@ -217,7 +237,7 @@ export interface Config { } ``` -来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts) +来源:[`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts) @@ -1162,10 +1182,20 @@ export interface Config { * omission defaults to 10. */ maxConcurrentJobsPerOwner?: number + /** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */ + retainBytes?: number + /** + * Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. + * Settlement keeps every byte the model cursor has not consumed on top of + * this cap; the first terminal model read then trims to it. + */ + settledRetainBytes?: number + /** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */ + pumpPollMs?: number } ``` -来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts) +来源:[`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts) @@ -2940,7 +2970,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:248`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:247`](../packages/core/system-prompt/src/index.ts) @@ -3175,7 +3205,7 @@ export interface Config { } /** - * How an unreported completion reaches an owner that is already idle: `wakeup` + * How an uncollected completion reaches an owner that is already idle: `wakeup` * opens a turn for it, `quiet` leaves it pending until something else wakes the * owner. A busy owner is injected either way. */ @@ -3234,7 +3264,7 @@ export interface Config { } ``` -来源:[`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts) +来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts) @@ -3419,7 +3449,7 @@ export interface Config { } ``` -来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts) +来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts) diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index cc1209ff9e..e6cf6f864d 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -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 docs/cookbook/adding-a-tool.md -adding-a-tool.md: 659f87bf88ac7fc4b8bc7dd65672c86e6b4326f6 -adding-a-tool.zh.md: 4804c22b22e5761e27c59392d76a322a7407af99 +adding-a-tool.md: 2deb608fb3f25cb3c816150ed678fa685892ef66 +adding-a-tool.zh.md: 5ad488c8b4c7f4678f11f31ea3669c425e3ea1b7 diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index 659f87bf88..2deb608fb3 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -52,7 +52,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id. -The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. +The spec supplies synchronous `cancel`, a non-rejecting `done` that settles after resource cleanup, and either pull `output` sources the registry pumps into the job's output ring or pushes through the `JobHandle` its starter receives; `dsh-tool-jobs` renders the model's consuming read from that ring. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer. ## Execution policy and observation diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 4804c22b22..5ad488c8b4 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -52,7 +52,7 @@ export function apply(ctx: Context) { 通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。 -producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 +spec 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及由注册表泵入 job 输出环的拉取式 `output` 源或经 starter 收到的 `JobHandle` 推送的追加;模型的消费式读取由 `dsh-tool-jobs` 从该环渲染。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 88b786a588..33dfb05b8e 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -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 docs/event-producer-consumer.md -event-producer-consumer.md: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb -event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6 +event-producer-consumer.md: 8101bb9aa3d6e3da1cca96a5593de5c52d6454cd +event-producer-consumer.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 619103ab6f..8101bb9aa3 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 00a13ca2aa..a57e1b7e94 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -23,11 +23,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) | | `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index dcaee90607..e22d460cd0 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -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 docs/module-graph.md -module-graph.md: 684f82c87648bbd6fa8a1e7f45405a2c2d4d9885 -module-graph.zh.md: cf3bca4f97c840a052399bd3765e7cf11270c396 +module-graph.md: af297ec0b0d143ad8917eac00e2faf44f3477b5d +module-graph.zh.md: f56bdc422d9e74d922cb015c9d35e020cbd69b89 diff --git a/docs/module-graph.md b/docs/module-graph.md index 684f82c876..af297ec0b0 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -114,6 +114,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -993,7 +994,6 @@ flowchart TD pkg_experimental_auto_review --> pkg_tools pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand @@ -1180,7 +1180,6 @@ flowchart TD pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_fs - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1325,6 +1324,7 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1556,7 +1556,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1583,7 +1583,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index cf3bca4f97..f56bdc422d 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -116,6 +116,7 @@ flowchart TD end subgraph group_api["packages/api"] pkg_api_gateway["api-gateway"] + pkg_api_job_controller["api-job-controller"] pkg_api_remotes["api-remotes"] pkg_api_session_controller["api-session-controller"] pkg_api_settings_controller["api-settings-controller"] @@ -995,7 +996,6 @@ flowchart TD pkg_experimental_auto_review --> pkg_tools pkg_tool_cordis --> pkg_agent pkg_tool_cordis --> pkg_cordis_host_runner - pkg_tool_cordis --> pkg_system_prompt pkg_tool_cordis --> pkg_tools pkg_host_plugin_inventory --> pkg_agent_presets pkg_host_plugin_inventory --> pkg_brand @@ -1182,7 +1182,6 @@ flowchart TD pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference pkg_api_session_controller --> pkg_fs - pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command pkg_api_session_controller --> pkg_scope @@ -1327,6 +1326,7 @@ flowchart TD | [`util-workspace-path`](../packages/util/workspace-path) | `util` | — | | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — | | [`llm`](../packages/llm/llm) | `llm` | — | +| [`api-job-controller`](../packages/api/job-controller) | `api` | — | | [`api-workspace-files`](../packages/api/workspace-files) | `api` | — | | [`cmdline`](../packages/boot/cmdline) | `boot` | — | | [`acp-app`](../packages/bundle/acp-app) | `bundle` | — | @@ -1558,7 +1558,7 @@ flowchart TD | [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-presets`](../packages/preset/agent-presets), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt) | | [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | -| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) | | [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`attachment`](../packages/attachment/attachment), [`llm`](../packages/llm/llm), [`mcp-resources`](../packages/mcp/mcp-resources), [`scope`](../packages/core/scope), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | `session` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`command-feedback`](../packages/feedback/command-feedback), [`llm`](../packages/llm/llm), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) | @@ -1585,7 +1585,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 5f847038ca..60dba9fa9e 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -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 docs/persistence-catalog.md -persistence-catalog.md: 11cea4328db6e69c1ee08994a6d36fe26e308a7c -persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff +persistence-catalog.md: dd52352ecff33c833154a5aa933767f0b2544b4a +persistence-catalog.zh.md: 71c0866cc2499ebaf573bc078ecb9f72a32ba128 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 11cea4328d..dd52352ecf 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5315,7 +5315,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7248,7 +7248,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -Sources: [`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 6207dc1702..71c0866cc2 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5317,7 +5317,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7250,7 +7250,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7` -来源:[`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 59b8a86b4a..c4e1c3b950 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -44608,7 +44608,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:394", + "packages/api/session-controller/src/types.ts:396", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:20", @@ -58191,7 +58191,7 @@ }, "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:399" + "packages/api/session-controller/src/types.ts:401" ] }, { diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml index 20d4e50962..f69e46f9a0 100644 --- a/docs/subsystems/jobs.i18n.yaml +++ b/docs/subsystems/jobs.i18n.yaml @@ -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 docs/subsystems/jobs.md -jobs.md: 4be335207b7dd94f08015ecda595a6b1cc301a7f -jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7 +jobs.md: d54b1ecdd8957c162268c8abbc1df6d764695ac7 +jobs.zh.md: 3b0db7c654d02ace451e73203923d816e9262230 diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md index 4be335207b..d54b1ecdd8 100644 --- a/docs/subsystems/jobs.md +++ b/docs/subsystems/jobs.md @@ -2,7 +2,7 @@ English | [中文](jobs.zh.md) -Types shared by long-running producers, `ctx.jobs`, and job controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts). +Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam consolidation Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) owns the current design and the [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) its origin; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) and the client-safe [`view.ts`](../../packages/jobs/jobs/src/view.ts) leaf. ## Ids and status @@ -19,45 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobSnapshot.detail`. +`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled. ## Producer contract -`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. +A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring. ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface JobStart { +interface JobSpec { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. */ outputLimitBytes?: number /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. */ - owner?: Agent + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the ring append and progress writers. */ - run(): JobHooks + run(job: JobHandle): JobHooks } ``` -`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs. +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void +} +``` + +`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. A job whose result is a value rather than a stream — a subagent's report, a workflow's rendered result — returns it as `JobOutcome.result`; the model's first read after settlement carries it once. ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -74,12 +112,6 @@ interface JobHooks { * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. - */ - readOutput?(): string } ``` @@ -88,73 +120,207 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link JobRegistry.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link JobRegistry.read}. + */ + result?: string +} +``` + +A pull source hands the registry a non-consuming offset reader — the subprocess `readFrom` family. The registry pumps every source at its own cadence (`pumpPollMs` on `dsh-jobs-local`) and drains it once more before settlement closes the ring, so producers fold nothing into `done`. + +```ts type-equiv +/** + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. + */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, the lossy flag, and the spill path the source currently keeps. + */ + read(fromByte: number): JobSourceRead +} +``` + +```ts type-equiv +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean + /** + * Host path of a file holding the complete stream, when the source + * currently keeps an intact one. Reported on every read, so the registry + * tracks it as source metadata: a later read without it withdraws the file. + */ + spillPath?: string +} +``` + +## The output ring + +Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`JobRegistry.read`), any number of observers read it at absolute byte offsets (`JobRegistry.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, refreshed by every pump read), not per-chunk metadata, so the model's dropped-output notice names it after the ring evicted the bytes and even after the gap chunk itself is gone. Browsers reach the roster and the ring through `job.list` and `job.follow`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below. + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, channel, and loss marker. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readAt}. */ +interface JobOutputRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobChunk[] + /** + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean } ``` ## Consumer views -Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service. +`JobView` is the one projection every reader consumes: the model tools, the browser roster, and the observation stream. `owner` carries the session id that fences access; the registry resolves the live `Agent` behind it for lifecycle cleanup and never hands the object out. `progress` is the producer's live line and is cleared at settlement; `detail` is the terminal reason, with a recorded kill reason merged in. ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId - /** The producer kind the job was registered with. */ - kind: JobKind + readonly id: JobId + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates and the complete-stream files + * behind it. `total` is the offset the next chunk starts at (0 while + * nothing was written); `earliest` is the oldest retained byte, greater + * than zero exactly when retention dropped the head. `spillPaths` lists the + * spill files the job's pull sources currently keep, in source order and + * deduplicated, and is absent while no source keeps one: it outlives any + * chunk, so a reader below `earliest` can still name where the bytes went. */ - reported: boolean + readonly output: { readonly total: number; readonly earliest: number; readonly spillPaths?: readonly string[] } } ``` +`JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` each accept the caller’s `SessionId`; omitting it permits only unowned jobs, while a session may also access its own jobs. + ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ +/** Output and post-read state returned by the consuming {@link JobRegistry.read}. */ interface JobRead { - /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. - */ - text: string + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string /** The job's state at read time. */ - snapshot: JobSnapshot + job: JobView } ``` +## Events + +The registry announces every commit through one filtered stream. Lifecycle events carry the projection after the commit they announce; `settled` names its cause so a completion reporter can skip a teardown; `output` carries only the id and the new total, so observers read from their own cursor and the registry never pushes payloads. + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link JobRegistry.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## Service behavior -The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onJobDone` and `onJobsChanged` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. +The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `list`, `get`, consuming `read`, non-consuming `readAt`, `kill`, and bounded `wait`, the filtered `events` stream, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the live `Agent` registered under the owner session when the job starts. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer. @@ -164,18 +330,52 @@ The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Defi Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) list(request: JobListRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -183,96 +383,73 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * List caller-owned and unowned jobs in registration order. + * @param caller - reading session; omission sees only unowned jobs. + * @returns fresh projections. */ -abstract list(caller?: Agent): JobSnapshot[] +abstract list(caller?: SessionId): JobView[] /** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or + * Project one job without changing its cursor. Throws for an unknown or * foreign job. + * @param id - job to look up. + * @param caller - reading session checked against the owner. + * @returns a fresh projection. + */ +abstract get(id: JobId, caller?: SessionId): JobView + +/** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result. Throws for an unknown or foreign job. + * @param id - job to read. + * @param caller - reading session checked against the owner. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ +abstract read(id: JobId, caller?: SessionId): JobRead + +/** + * Read retained ring output without moving the model cursor. Resume with + * a previous read's `next`; an offset inside a retained chunk returns the + * whole chunk (its `at` may precede `from`). Throws for a negative or + * non-integer offset, or an unknown or foreign job. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading session checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readAt(id: JobId, from: number, caller?: SessionId): JobOutputRead + +/** + * Request cancellation, then mark the job stopping. A producer throw + * propagates without changing job state. A supplied reason is merged into + * terminal `detail` when the job settles `killed`. Throws for an unknown + * or foreign job. * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. + * @param caller - killing session checked against the owner. + * @param reason - cancellation reason forwarded verbatim to the producer. * @returns `requested` for live work, otherwise `already-finished`. */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' +abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished' /** * Wait for settlement or timeout without cancelling the job. Caller abort * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. + * projection wins. Rejects for an invalid timeout or an unknown or foreign + * job. * @param id - job to wait for. * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. + * @param caller - waiting session checked against the owner. * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. + * @returns projection at settlement or timeout. */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void +abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -284,7 +461,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.md) +Types: [SessionId](core.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md index 9dc390eb30..3b0db7c654 100644 --- a/docs/subsystems/jobs.zh.md +++ b/docs/subsystems/jobs.zh.md @@ -2,7 +2,7 @@ [English](jobs.md) | 中文 -长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 负责设计;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 中的确切字段和变体。 +长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[seam 收敛 Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md) 负责当前设计,[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 记录其起源;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 与客户端安全叶子 [`view.ts`](../../packages/jobs/jobs/src/view.ts) 中的确切字段与变体。 ## ID 与状态 @@ -19,45 +19,83 @@ interface JobKindMap { } ``` -`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `JobSnapshot.detail`。 +`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。 ## 生产方约定 -`JobStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。 +`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。 ```ts type-equiv /** * Producer declaration passed to {@link JobRegistry.start}. The runtime * preflights access and cleanup before invoking {@link run}; the producer owns - * execution resources while the runtime owns identity and lifecycle state. + * execution resources while the runtime owns identity, lifecycle state, and + * the output ring. */ -interface JobStart { +interface JobSpec { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: JobKind /** One-line model-facing label (the command; the delegation description). */ label: string + /** + * Owning session. Access is fenced by it, and the owner's live Agent must be + * the one currently registered under that id: its disposal cancels and + * awaits the job. Omitting the owner creates an unowned job, open to any + * caller until service disposal. + */ + owner?: SessionId /** * Optional UTF-8 byte cap for each complete model-facing completion notice or - * output read, including controller status metadata. + * output read, including controller status metadata. Independent of ring + * retention: it bounds the consuming model surface, never observers. */ outputLimitBytes?: number /** - * Owning live agent. Access is fenced by its session id, and agent disposal - * cancels and awaits the job. The instance must be the one currently - * registered under its agent id. Omitting the owner creates an unowned job, - * open to any caller until service disposal. + * Pull sources the registry pumps into the ring at its own cadence. + * Producers that narrate their own progress use {@link JobHandle.append} + * instead; a job may use both. */ - owner?: Agent + output?: readonly JobOutputSource[] /** * Start the work after preflight and synchronously return its hooks. Called - * once; a throw leaves nothing registered, and the producer must clean up any - * partially started resources. + * once with the job's producer face; a throw leaves nothing registered (the + * spent ordinal is skipped), and the producer must clean up any partially + * started resources. + * @param job - the issued id plus the ring append and progress writers. */ - run(): JobHooks + run(job: JobHandle): JobHooks } ``` -`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。 +```ts type-equiv +/** + * Producer face of one registered job, handed to {@link JobSpec.run} and + * valid for the job's whole life. All methods are synchronous. Writes staged + * inside the starter call are retained and become visible with the + * registration commit; after settlement — the producer's own outcome, a kill, + * or a registry-forced teardown end — writes log and drop instead of + * throwing, so a producer's trailing flush cannot break its own teardown path. + */ +interface JobHandle { + /** The registry-issued id (`-N`). */ + readonly id: JobId + /** + * Append one chunk to the output ring. Offsets advance by the chunk's UTF-8 + * byte length; an empty chunk is dropped without waking observers. + * @param text - the chunk text, exactly as produced. + * @param options - stream label and gap marker. + */ + append(text: string, options?: JobAppendOptions): void + /** + * Replace the live progress line (`3/10`, the current phase). Settlement + * clears it; the terminal reason travels in {@link JobOutcome.detail}. + * @param line - the new progress line. + */ + updateProgress(line: string): void +} +``` + +`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。结果是一个值而非流的 job——subagent 的报告、workflow 渲染出的结果——把它作为 `JobOutcome.result` 返回;模型在结算后的第一次读取携带它一次。 ```ts type-equiv /** Hooks through which the runtime controls and observes producer work. */ @@ -74,12 +112,6 @@ interface JobHooks { * registry record without claiming that the work stopped. */ done: Promise - /** - * Consume output produced since the previous call. The producer formats - * truncation and spill notices. Absence marks a final-output-only job; each - * job has one consuming cursor. - */ - readOutput?(): string } ``` @@ -88,73 +120,207 @@ interface JobHooks { interface JobOutcome { /** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' - /** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */ + /** + * Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`). + * When the job settles `killed` after a {@link JobRegistry.kill} with a + * reason, the registry appends that reason. + */ detail?: string - /** Final output for jobs without `readOutput`; stream jobs leave it unset. */ - output?: string + /** + * Return value for jobs whose result is a value rather than a stream (a + * workflow's rendered result, a subagent's report). The output ring carries + * the stream; this is handed out once by the model's next {@link JobRegistry.read}. + */ + result?: string +} +``` + +拉取源把一个非消耗的偏移读取器——子进程的 `readFrom` 家族——交给注册表。注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送每个源,并在结算封环之前再排干一次,因此生产方不需要把任何东西折进 `done`。 + +```ts type-equiv +/** + * A pull source the registry pumps into the job's output ring — the subprocess + * `readFrom` family. The registry owns the cadence and drains every source one + * last time before settlement closes the ring, so a producer folds nothing + * into its `done`. + */ +interface JobOutputSource { + /** Stream label attached to every chunk this source yields. */ + channel?: JobChannel + /** + * Read everything captured since `fromByte` without consuming it. + * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first). + * @returns the delta text, the next offset, the lossy flag, and the spill path the source currently keeps. + */ + read(fromByte: number): JobSourceRead +} +``` + +```ts type-equiv +/** One incremental read from a {@link JobOutputSource}. */ +interface JobSourceRead { + /** Text captured since the requested offset (the whole retained tail when lossy). */ + text: string + /** Whole-stream offset to resume from on the next read. */ + nextOffset: number + /** True when the requested offset slid out of the source's retained window. */ + lossy: boolean + /** + * Host path of a file holding the complete stream, when the source + * currently keeps an intact one. Reported on every read, so the registry + * tracks it as source metadata: a later read without it withdraws the file. + */ + spillPath?: string +} +``` + +## 输出环 + +每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`JobRegistry.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`JobRegistry.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会刷新),不是逐块元数据,所以环淘汰了字节之后、甚至缺口 chunk 本身也被淘汰之后,模型的丢失输出提示仍能点名该文件。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.list` 与 `job.follow` 触达名册与环,其帧列于下文的 Cordis API 一节。 + +```ts type-equiv +/** One chunk of a job's output ring: absolute offset, text, channel, and loss marker. */ +interface JobChunk { + /** Absolute offset of the chunk's first byte; offsets never move once assigned. */ + readonly at: number + /** Chunk text exactly as appended (possibly tail-trimmed by retention). */ + readonly text: string + /** Stream label, when the producer supplied one. */ + readonly channel?: JobChannel + /** Bytes immediately before this chunk were lost, at the producer or to retention. */ + readonly gapBefore?: true +} +``` + +```ts type-equiv +/** Result of one non-consuming {@link JobRegistry.readAt}. */ +interface JobOutputRead { + /** Retained chunks overlapping `[from, total)`, in offset order. */ + chunks: readonly JobChunk[] + /** + * Offset to resume from — the ring's current `total`. Always a chunk + * boundary: appends land whole and trimming only advances chunk starts, and + * consumers concatenate `chunks` under that assumption, so a provider + * serving partial chunks would silently duplicate text. + */ + next: number + /** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean } ``` ## 消费方视图 -快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。 +`JobView` 是每个读者都消费的唯一投影:模型工具、浏览器名册与观测流。`owner` 携带划定访问边界的会话 id;注册表为生命周期清理解析其背后的活体 `Agent`,从不把该对象交出去。`progress` 是生产方的实时行,结算时清除;`detail` 是终态原因,合并了记录下来的 kill 原因。 ```ts type-equiv /** - * A read-only projection of one job, safe to hand to listeners and tools — - * a fresh object per call, never live registry state. + * Read-only projection of one job — a fresh object per call, never live + * registry state. The model tools, the browser roster, and the observation + * stream all consume this one shape. */ -interface JobSnapshot { +interface JobView { /** The registry-issued id (`-N`). */ - id: JobId - /** The producer kind the job was registered with. */ - kind: JobKind + readonly id: JobId + /** + * The producer kind the job was registered with: a Host-registered + * `JobKind`, carried as an open string because a browser bundle or a Remote + * codec sees only the `JobKindMap` merges its own program compiles. + */ + readonly kind: string /** The producer-supplied one-line label. */ - label: string - /** Producer-owned cap for complete model-facing notices and output reads. */ - outputLimitBytes?: number - /** - * Owner session id used for authorization and correlation; absent for - * unowned jobs. Completion listeners receive the exact {@link Agent} - * separately through {@link JobDoneListener}. - */ - ownerSession?: SessionId + readonly label: string + /** Owning session; absent for an unowned job, which every caller can see. */ + readonly owner?: SessionId + /** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */ + readonly outputLimitBytes?: number /** Current lifecycle state. */ - status: JobStatus - /** Kind-specific status detail, present once the producer supplied one (usually terminal). */ - detail?: string + readonly status: JobStatus + /** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */ + readonly progress?: string + /** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */ + readonly detail?: string /** Epoch ms when the job was registered. */ - startedAt: number - /** Epoch ms when the job settled; absent while `running`/`stopping`. */ - finishedAt?: number + readonly startedAt: number + /** Epoch ms when the job settled; absent while live. */ + readonly finishedAt?: number /** - * True when a kill, read, wait, or teardown cancel has reported or committed - * to report the terminal state. Completion reporters suppress redundant - * notices when set. Teardown claims it because the owner or service being - * destroyed leaves no reader: a reporter that opens a turn on notice would - * otherwise spend a model request per teardown layer. + * The output ring's absolute coordinates and the complete-stream files + * behind it. `total` is the offset the next chunk starts at (0 while + * nothing was written); `earliest` is the oldest retained byte, greater + * than zero exactly when retention dropped the head. `spillPaths` lists the + * spill files the job's pull sources currently keep, in source order and + * deduplicated, and is absent while no source keeps one: it outlives any + * chunk, so a reader below `earliest` can still name where the bytes went. */ - reported: boolean + readonly output: { readonly total: number; readonly earliest: number; readonly spillPaths?: readonly string[] } } ``` +`JobRegistry` 的 `list`、`get`、`read`、`readAt`、`kill` 和 `wait` 各自接收调用方的 `SessionId`;省略调用方时仅可访问无主 job,传入会话时还可访问该会话拥有的 job。 + ```ts type-equiv -/** Output and post-read state returned by {@link JobRegistry.read}. */ +/** Output and post-read state returned by the consuming {@link JobRegistry.read}. */ interface JobRead { - /** - * Stream kinds: the consuming delta since the previous read. Final-output - * kinds: empty while live, the terminal {@link JobOutcome.output} (or - * empty) once settled — idempotent, never consumed. - */ - text: string + /** Ring chunks appended since the model cursor, in offset order; every channel included. */ + chunks: readonly JobChunk[] + /** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */ + lossy: boolean + /** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */ + result?: string /** The job's state at read time. */ - snapshot: JobSnapshot + job: JobView } ``` +## 事件 + +注册表通过一条带过滤的流宣布每次提交。生命周期事件携带其所宣布的提交之后的投影;`settled` 标出原因,完成播报方据此跳过 teardown;`output` 只携带 id 与新的 total,观察者从自己的游标读取,注册表从不推送负载。 + +```ts type-equiv +/** + * One lifecycle or output event. Lifecycle events carry the job's projection + * after the commit they announce; `output` carries only the id and the new + * total, so an observer schedules a {@link JobRegistry.readAt} from its own + * cursor and the registry never pushes payloads. + */ +type JobEvent = + | { + /** Registration commit, progress line change, stopping transition, or removal from the visible set. */ + readonly type: 'registered' | 'progress' | 'stopping' | 'removed' + readonly job: JobView + } + | { + readonly type: 'settled' + readonly job: JobView + readonly cause: JobSettleCause + } + | { + readonly type: 'output' + readonly id: JobId + /** Owning session, absent for an unowned job. */ + readonly owner?: SessionId + /** The ring's total after the append (or at settlement, which ends the stream). */ + readonly total: number + } +``` + +```ts type-equiv +/** + * Who a subscription hears about. `{ owner }` delivers that session's jobs + * plus every unowned job (the set that session can see). `{ owners: 'scope' }` + * delivers the owners composed under the subscribing context — one registry + * serves every composition in the process, and a mount under one preset must + * not hear another preset's agents. `{ owners: 'all' }` delivers everything. + */ +type JobEventFilter = + | { readonly owner: SessionId } + | { readonly owners: 'all' | 'scope' } +``` + ## 服务行为 -抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onJobDone` 与 `onJobsChanged` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是其进程局部 Service Provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service Provider 的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md),注册表生命周期与准入策略见 [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md),面向模型的 Consumer 见 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)。 +抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、各自接收调用方的 `list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md) 了解 Service Definition 约定,[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md) 了解注册表生命周期与准入策略,[`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md) 了解面向模型的 Consumer。 @@ -164,18 +330,52 @@ interface JobRead { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + +### `ctx.jobController` — `JobController` + +Host service backing the generated `ctx.remote.job` namespace. + +```ts cordis-catalog +/** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ +@Remote({ mode: 'stream' }) list(request: JobListRequest, signal: AbortSignal): AsyncIterable + +/** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ +@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable +``` + +Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts) + ### `ctx.jobs` — `JobRegistry` (abstract seam) -Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). +Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior). Implementations must honor these semantics: -- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left. +- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left. - Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary. -- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously. -- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event. +- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it. +- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other. +- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle. ```ts cordis-catalog /** @@ -183,96 +383,73 @@ Implementations must honor these semantics: * admission before starting and atomically registering work. Any preflight * rejection leaves no job id or execution resource. A throwing starter * leaves nothing registered; after it returns, registration cannot fail. - * Settlement records the outcome, notifies listeners, and releases waiters. - * @param spec - job identity, owner, and synchronous starter. + * @param spec - job identity, owner, output sources, and synchronous starter. * @returns the registry-issued `-N` id. */ -abstract start(spec: JobStart): JobId +abstract start(spec: JobSpec): JobId /** - * List caller-owned and unowned jobs in registration order without exposing - * another session's labels. - * @param caller - reading agent; a non-agent caller sees only unowned jobs. - * @returns fresh snapshots. + * List caller-owned and unowned jobs in registration order. + * @param caller - reading session; omission sees only unowned jobs. + * @returns fresh projections. */ -abstract list(caller?: Agent): JobSnapshot[] +abstract list(caller?: SessionId): JobView[] /** - * Return a non-consuming snapshot without changing its read cursor or notice - * state. Throws for an unknown or foreign job. - * @param id - job to look up. - * @param caller - reading agent checked against the owner. - * @returns a fresh snapshot. - */ -abstract get(id: JobId, caller?: Agent): JobSnapshot - -/** - * Read the next stream delta, or the idempotent final output after settlement. - * A terminal read marks the job reported. Throws for an unknown or foreign - * job. - * @param id - job to read. - * @param caller - reading agent checked against the owner. - * @returns output text and the post-read snapshot. - */ -abstract read(id: JobId, caller?: Agent): JobRead - -/** - * Request cancellation, then mark the job stopping and reported. A producer - * throw propagates without changing job state. Throws for an unknown or + * Project one job without changing its cursor. Throws for an unknown or * foreign job. + * @param id - job to look up. + * @param caller - reading session checked against the owner. + * @returns a fresh projection. + */ +abstract get(id: JobId, caller?: SessionId): JobView + +/** + * Consume the ring from the model cursor and advance it to the current + * total. After settlement the first read also carries the producer's + * result. Throws for an unknown or foreign job. + * @param id - job to read. + * @param caller - reading session checked against the owner. + * @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection. + */ +abstract read(id: JobId, caller?: SessionId): JobRead + +/** + * Read retained ring output without moving the model cursor. Resume with + * a previous read's `next`; an offset inside a retained chunk returns the + * whole chunk (its `at` may precede `from`). Throws for a negative or + * non-integer offset, or an unknown or foreign job. + * @param id - job to read. + * @param from - absolute byte offset to read from (0 for the retained head). + * @param caller - reading session checked against the owner. + * @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag. + */ +abstract readAt(id: JobId, from: number, caller?: SessionId): JobOutputRead + +/** + * Request cancellation, then mark the job stopping. A producer throw + * propagates without changing job state. A supplied reason is merged into + * terminal `detail` when the job settles `killed`. Throws for an unknown + * or foreign job. * @param id - job to cancel. - * @param caller - killing agent checked against the owner. - * @param reason - logged reason forwarded to the producer. + * @param caller - killing session checked against the owner. + * @param reason - cancellation reason forwarded verbatim to the producer. * @returns `requested` for live work, otherwise `already-finished`. */ -abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished' +abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished' /** * Wait for settlement or timeout without cancelling the job. Caller abort * rejects only while the job is live; after settlement the terminal - * snapshot wins so a notice suppressed for this waiter is still delivered. - * Throws for invalid, unknown, or foreign input. + * projection wins. Rejects for an invalid timeout or an unknown or foreign + * job. * @param id - job to wait for. * @param timeoutMs - positive finite wait bound in milliseconds. - * @param caller - waiting agent checked against the owner. + * @param caller - waiting session checked against the owner. * @param signal - optional cancellation of the wait itself. - * @returns snapshot at settlement or timeout. + * @returns projection at settlement or timeout. */ -abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise - -/** - * Register an effect-scoped completion listener. It receives the settlements - * of the owners its registering context's scope covers; each listener is - * contained; returned promises are observed but not awaited. No listener runs - * after service disposal. - * @param listener - receives each terminal snapshot and its exact owner. - * @returns disposer that unregisters the listener. - */ -abstract onJobDone(listener: JobDoneListener): () => void - -/** -/** - * Register an effect-scoped observer of visible-set changes. It fires after - * every commit that changes what {@link list} returns for that owner — - * registration, every stopping transition (including the one teardown - * performs before it awaits a slow producer), settlement, owner-disposal - * removal, and the emptying that service disposal commits — so an observer - * re-reads rather than accumulating deltas. - * - * Delivery is owner-relative on the same terms as {@link onJobDone}: an - * observer registered from an unscoped context — a host composition's own - * carrier — sees every owner, while one registered under an agent - * composition's scope sees exactly the agents composed under it. - * - * This is not a superset of {@link onJobDone}: that one delivers the terminal - * record under first-wins semantics a job controller couples to notice - * delivery, while this one carries no delivery meaning and marks nothing - * reported. Listeners are contained and never awaited. - * @param listener - receives the owner whose visible set changed, or - * `undefined` when an unowned job changed and every caller's set did. - * @returns disposer that unregisters the listener. - */ -abstract onJobsChanged(listener: JobsChangedListener): () => void +abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise /** * Attach an effect-scoped controller that can read and stop jobs. It serves the @@ -284,7 +461,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void abstract attachController(name: string): () => void ``` -Types: [Agent](core.zh.md) +Types: [SessionId](core.zh.md) Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts) diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 83eecfd1c7..878b270089 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -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 docs/subsystems/session.md -session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149 -session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a +session.md: be48d4833bacbac31f3765e0b4d9cdcf6da9f4d7 +session.zh.md: 093fc6d570e93dc5be42d8779fc4d5965085b7e0 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 62f7785d4d..be48d4833b 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -758,6 +758,8 @@ The backends that consume this contract are on [persistence.md](persistence.md). `SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary. +The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification. + @@ -845,6 +847,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 7cd3c38162..093fc6d570 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -762,6 +762,8 @@ interface TurnEndReasonMap { `SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。 +可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。 + @@ -849,6 +851,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | */ @Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise +/** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ +@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. diff --git a/docs/subsystems/shell.i18n.yaml b/docs/subsystems/shell.i18n.yaml index be9a099f49..9c6c29422c 100644 --- a/docs/subsystems/shell.i18n.yaml +++ b/docs/subsystems/shell.i18n.yaml @@ -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 docs/subsystems/shell.md -shell.md: de04083093a1e53aedd310f388f778638d858ce7 -shell.zh.md: d68b1b2cfba3214a23e9120f19fc9a029a181a18 +shell.md: 734b9e7c72948be23d2c6a2c82c6af27c1e2812b +shell.zh.md: bfe455c2e5f680f743188e8accb70008a95ab8f9 diff --git a/docs/subsystems/shell.md b/docs/subsystems/shell.md index de04083093..734b9e7c72 100644 --- a/docs/subsystems/shell.md +++ b/docs/subsystems/shell.md @@ -195,6 +195,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains, including the provider-failure note a + * rejected spawn leaves on stderr. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. + */ + observed: ShellObservedStreams /** * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/shell.zh.md b/docs/subsystems/shell.zh.md index d68b1b2cfb..bfe455c2e5 100644 --- a/docs/subsystems/shell.zh.md +++ b/docs/subsystems/shell.zh.md @@ -195,6 +195,13 @@ interface ShellProcess { * full-stream spill files when available. */ readOutput(): ShellProcessRead + /** + * Non-consuming offset readers over the same captured streams the consuming + * {@link readOutput} cursor drains, including the provider-failure note a + * rejected spawn leaves on stderr. Independent observers read here at their + * own offsets without stealing bytes from `readOutput`. + */ + observed: ShellObservedStreams /** * Terminate the provider-managed range. Returns false when it had already finished * (no-op); idempotent. diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 30b50d6a0a..091c70c940 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -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 docs/subsystems/sidebar-right.md -sidebar-right.md: 27e5219aec8997cf0d168ac9ea2665baaf943906 -sidebar-right.zh.md: 63f2c1081009778e191789038ffd1923312fc55b +sidebar-right.md: e3a4ffcf4e4b17975c10c0358f6f53dc5eea6ec2 +sidebar-right.zh.md: a588dc99b92314c54ea1309e4a44d305e23b970f diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 27e5219aec..e3a4ffcf4e 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -8,7 +8,7 @@ This page is the reference for the subsystem's contracts: addresses, tab-type re ## Position and ownership -One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller mounts that seat only while Conversation is selected; a reload returns every session to the collapsed default, and switching sessions keeps each surface where it was ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries. +One docking surface exists per Session, held in a session-scoped slot store and drawn by `rightbar.session`. The root-scoped `rightbar` controller retains separate seats for the selected Session and background Sessions with initialized `keepMounted` bodies; only the foreground Conversation is visible. Reload restores saved layouts ([state](../../packages/client/ui-sidebar-right/README.md#state)). The surface's every change is one recorded history entry computed by the kit's pure planners; a docked pane never stays empty, and an empty root pane receives the default page selected from registered guide entries. A tab type is two registrations that share the definition's `id`: a static definition in `ctx.sidebarRightTabs` saying which addresses its `kind` opens, and a keyed slot registration supplying its body. The framework injects `useTabInfo()` for live Sidebar, pane and tab information; each type keeps its own state in its slot store. Packages import each other's declarations only as types. @@ -43,6 +43,7 @@ Tab identity is the pair `(kind, address)`: the registry's claim uses the addres | `priority` | One of three literal bands: `extension` (the default and the highest: a type from outside the product outranks every shipped viewer), `builtin` (types shipped with the product), `fallback` (plain-content viewers anything more specific should beat). | | `canOpen(address)` | Optional synchronous veto of a glob match; it runs on every routing decision. | | `title(address)` | The chip's text, captured into the layout record when the tab opens and never rewritten. | +| `keepMounted` | Optional lazy retention of visited bodies across hiding, Session switches and docking; the owning View holds the Session reference. | | `guide` | Optional entry boxes for the guide page: `{ order, title(), description?(), icon? }`. Picking a box opens the contributing type as a page; omit to stay off the page. | Routing is a ranked claim. `candidates(address)` ranks the types whose patterns match and whose `canOpen` does not veto: by band, then by the length of the longest matched pattern, then by registration order. `claim(address, kind?)` picks the first candidate, or the named `kind` outright — its globs are skipped, its `canOpen` still applies — and returns `{ kind, contentId: address, title }`. An address no type claims throws: it is a wiring mistake, not a user error. @@ -101,7 +102,7 @@ The Sidebar declares four extension slots; its document tab declares the additio | `sidebar.right.tab.menu.item` | list, Session scope | Content-level actions appended after the kit's own layout actions. An item that acts must call the owner's `dismiss()`. | | `sidebar.right.tab.document` | keyed by the document implementation's `id`, Session scope | The selected file renderer inside the document tab; the parent owns shared loading and toolbar controls. | -A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. Docked bodies are visible only while expanded and active; docked titles need only expansion; floats stay visible. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. +A body, title and guide replacement receive the framework-injected `useTabInfo()`. It returns `{ sidebar, panel, tab }`: `sidebar` holds `expanded` and `fullscreen`, `panel.id` names the containing pane, and `tab` contains its record fields plus `visible`, `navigation`, `signal`, and `actions`. All tabs require a foreground Session. Docked bodies also require expansion and selection; docked titles require expansion; foreground floats survive collapse. `signal` aborts when the record disappears or the plugin unloads, not on hiding or Session switching. `tab.actions` provides `openResource`, `openTab`, and `close`, bound to the tab's own Session. Open placement defaults to its current pane; `revealIfOpened` defaults to `true`, and `replaceTab: true` replaces this record in the same history entry. Menu entries retain plain `tab` and `dismiss` owner parameters. `navigation.revision` increments on every navigation to the tab whether or not `params` changed, so a body can act on "navigated again" alone; it is `1` for a tab opened by address and `0` for a record nobody opened by address — a seeded guide, or a tab restored by undo. The Tab domain holds one occurrence per open record: a record that appears is pinned in the resource model, so switching tabs unmounts a body without dropping its content; a record that vanishes is aborted and dropped; a record restored by undo is a new occurrence ([Tab domain](../../packages/client/ui-sidebar-right/README.md#the-tab-domain)). diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index 63f2c10810..a588dc99b9 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -8,7 +8,7 @@ ## 定位与归属 -每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器仅在选中 Conversation 时挂载该席位;刷新页面后每个会话回到折叠的默认态,切换会话时各自的面保持原状([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。 +每个会话恰有一个停靠面,保存在会话作用域的 slot store 里、由 `rightbar.session` 绘制。root 作用域的 `rightbar` 控制器为选中 Session 和拥有已初始化 `keepMounted` 正文的后台 Session 保留独立席位;只有前台 Conversation 可见。刷新会恢复已保存的布局([状态](../../packages/client/ui-sidebar-right/README.zh.md#state))。面的每一次变化都是 kit 纯规划器算出的一条历史记录;停靠的 pane 从不空着,根 pane 为空时会加入根据已注册引导入口选出的默认页。 一个 tab 类型是共用定义 `id` 的两次注册:在 `ctx.sidebarRightTabs` 里的静态定义说明其 `kind` 打开哪些地址,一次 keyed slot 注册提供它的正文。框架注入 `useTabInfo()` 以读取 Sidebar、窗格和标签的实时信息;各类型把自身状态放在 slot store 里。各包之间只以类型形式引用彼此的声明。 @@ -43,6 +43,7 @@ tab 身份是 `(kind, address)` 二元组:注册表的认领把地址原文用 | `priority` | 三档字面量之一:`extension`(缺省且最高:产品之外的类型压过所有内置查看器)、`builtin`(随产品发布的类型)、`fallback`(任何更具体的类型都应压过的纯内容查看器)。 | | `canOpen(address)` | 可选的同步否决,对 glob 命中生效;每次路由决策都会调用。 | | `title(address)` | chip 文本,在 tab 打开时捕获进布局记录,之后不再改写。 | +| `keepMounted` | 可选的懒保活:已访问正文跨隐藏、Session 切换与停靠切换保留,由所属 View 持有 Session reference。 | | `guide` | 可选的引导页入口框:`{ order, title(), description?(), icon? }`。点一框即把贡献它的类型作为页面打开;省略即不上引导页。 | 路由是一次排序认领。`candidates(address)` 对模式命中且未被 `canOpen` 否决的类型排序:先按档,再按最长命中模式的长度,最后按注册顺序。`claim(address, kind?)` 取第一个候选,或直接用点名的 `kind`——跳过它的 glob,但 `canOpen` 仍生效——返回 `{ kind, contentId: address, title }`。没有任何类型认领的地址会抛错:这是接线错误,不是用户错误。 @@ -101,7 +102,7 @@ Sidebar 声明四个扩展 slot;其文档 tab 另行声明下表中的 keyed | `sidebar.right.tab.menu.item` | list,会话作用域 | 追加在 kit 自身布局动作之后的内容级动作。执行了动作的条目必须调用 owner 的 `dismiss()`。 | | `sidebar.right.tab.document` | 按文档实现的 `id` keyed,会话作用域 | 文档 tab 内选中的文件渲染器;父组件拥有共享加载与工具栏控件。 | -正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。停靠正文仅在展开且活跃时可见;停靠标题只要求展开;浮窗保持可见。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 +正文、标题与引导页替换项接收框架注入的 `useTabInfo()`。它返回 `{ sidebar, panel, tab }`:`sidebar` 包含 `expanded` 与 `fullscreen`,`panel.id` 标识所属窗格,`tab` 包含记录字段以及 `visible`、`navigation`、`signal` 和 `actions`。所有 tab 都要求 Session 位于前台。停靠正文还要求展开且选中;停靠标题要求展开;前台浮窗不随整栏收起。`signal` 在记录消失或插件卸载时中止,不因隐藏或切换 Session 而中止。`tab.actions` 提供绑定到标签所属 Session 的 `openResource`、`openTab` 与 `close`。打开位置缺省为当前所属窗格;`revealIfOpened` 缺省为 `true`,`replaceTab: true` 在同一历史项中替换本记录。菜单项保留普通的 `tab` 与 `dismiss` owner 参数。 `navigation.revision` 在每次导航到该 tab 时递增,`params` 不变也递增,正文可仅凭「又被导航了」行动;按地址打开的 tab 为 `1`,没有人按地址打开的记录——种入的引导、撤销恢复的 tab——为 `0`。Tab 域为每条打开的记录保有一个 occurrence:记录出现即在资源模型里钉住,因此切换 tab 卸载正文也不丢内容;记录消失即中止并丢弃;撤销恢复的记录是新的 occurrence([Tab 域](../../packages/client/ui-sidebar-right/README.zh.md#the-tab-domain))。 diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index 50d7a12706..152904f4d3 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.i18n.yaml @@ -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 docs/subsystems/slots.md -slots.md: 1e820fd880163d565a72733372fa4f2cc70a62b5 -slots.zh.md: c5bec8e752b9d9e1922f608449ca3249d0477062 +slots.md: 3a2eb7d0ff7297f6c80fa083134144cbb7a6864e +slots.zh.md: 16a529aa46837af0aaa675271e765bd88f29e623 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index 1e820fd880..3a2eb7d0ff 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -6,6 +6,8 @@ Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md). +`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount. + ## Declaration and lifecycle `SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location. diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index c5bec8e752..16a529aa46 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -6,6 +6,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](. 本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。 +`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。 + ## 声明与生命周期 `SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。 diff --git a/docs/subsystems/web-client.i18n.yaml b/docs/subsystems/web-client.i18n.yaml index 0dfc4fa69c..0f1163393e 100644 --- a/docs/subsystems/web-client.i18n.yaml +++ b/docs/subsystems/web-client.i18n.yaml @@ -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 docs/subsystems/web-client.md -web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6 -web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902 +web-client.md: c43b7553d1a223b82fec73c7431e4655cf1cbee1 +web-client.zh.md: d98febb7494a2b2fe082f8544850dbf67ee75794 diff --git a/docs/subsystems/web-client.md b/docs/subsystems/web-client.md index 6bcf9bf84e..c43b7553d1 100644 --- a/docs/subsystems/web-client.md +++ b/docs/subsystems/web-client.md @@ -40,10 +40,10 @@ Each API controller package owns a paired Host and Client face. The Host side ow [`api/session-controller`](../../packages/api/session-controller/README.md) exposes Host commands for list, search, creation, prompt, queue, cancellation, pagination, and follow/control streams. Its Client side is organized as `ClientSessions → SessionManager → Session`: - `ClientSessions` provides `ctx.sessions`, owns references, source counts, Session scopes, and stable `SessionBinding` objects, and projects catalog state without selecting a global current Session. -- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates. +- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, projection stores, subagent catalogs, and conflict ordering between pulls and later updates. - Each `Session` owns one contiguous logical-event window represented by `SessionEventLikeEntry` values, paging, follow, prompt/control state, and the observable snapshot consumed by adapters. -The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates. +The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies projection updates. ### Workspaces @@ -66,7 +66,8 @@ Web and desktop share the [developer-tool preference](../../packages/client/ui-s | Path | Sequence | |---|---| | durable Session display | Host Session log → packed Remote `follow`/`page` history → Client `SessionEventLikeEntry` window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React | -| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection stores → Session and list snapshots → standard hooks → components | +| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` projection stores → Session and list snapshots → standard hooks → components | +| Background jobs | Host job registry → `job.list` / `job.follow` → [`ClientJobs`](../../packages/api/job-controller/README.md) roster and output views → job list and panels | | Workspace state | Host Workspace baseline and increments → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar, hero, and navigation entries | | scoped interaction | Host Cordis waterfall → API Remotes `$events` → `ctx.remote.$on()` on the Session Context → owning UI package → result or `next()` | | user command | component callback → registration inject face or Slot owner → `ctx.sessions`, `ctx.workspaces`, or generated scoped Remote → Host Controller → authoritative update → stream or event projection back to the Client | diff --git a/docs/subsystems/web-client.zh.md b/docs/subsystems/web-client.zh.md index 9c697964a0..d98febb749 100644 --- a/docs/subsystems/web-client.zh.md +++ b/docs/subsystems/web-client.zh.md @@ -40,10 +40,10 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe [`api/session-controller`](../../packages/api/session-controller/README.zh.md)公开 Session list、search、creation、prompt、queue、cancellation、pagination 及 follow/control stream 等 Host command。其 Client 侧按 `ClientSessions → SessionManager → Session` 组织: - `ClientSessions` 提供 `ctx.sessions`,拥有 reference、source count、Session scope 与稳定的 `SessionBinding` object,并投影不含全局 current Session 选择的 catalog state。 -- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。 +- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。 - 每个 `Session` 拥有一段由 `SessionEventLikeEntry` value 表示的连续逻辑 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。 -持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event` 或 `chunks` 判别字段和字段对齐的内部 `event`;journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。 +持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event` 或 `chunks` 判别字段和字段对齐的内部 `event`;journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 projection update。 ### Workspaces @@ -66,7 +66,8 @@ Web 和桌面端共享[开发者工具偏好](../../packages/client/ui-settings/ | 路径 | 顺序 | |---|---| | 持久 Session 展示 | Host Session log → packed Remote `follow`/`page` 历史 → Client `SessionEventLikeEntry` window → Conversation Context → target snapshot(`chat`、`trajectory` 或其他已注册 target)→ Slot view → React | -| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection store → Session 与 list snapshot → 标准 hook → component | +| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` projection store → Session 与 list snapshot → 标准 hook → component | +| 后台任务 | Host job 注册表 → `job.list` / `job.follow` → [`ClientJobs`](../../packages/api/job-controller/README.zh.md) 名册与输出视图 → 任务列表和面板 | | Workspace 状态 | Host Workspace baseline 与 increment → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar、hero 与 navigation entry | | scoped interaction | Host Cordis waterfall → API Remotes `$events` → Session Context 上的 `ctx.remote.$on()` → 所属 UI 包 → result 或 `next()` | | 用户 command | component callback → 注册项 inject face 或 Slot owner → `ctx.sessions`、`ctx.workspaces` 或生成的 scoped Remote → Host Controller → 权威 update → stream 或 event projection 回到 Client | diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 54934936dc..8cdff8b24e 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -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 docs/tool-catalog.md -tool-catalog.md: a4441a1b6bdbd0c2f7cedf506244a8a4a2a1ca97 -tool-catalog.zh.md: 0a82afad88f5362ca997f2e563f1b53c4bf633e8 +tool-catalog.md: c60d3979e65f3c5a2bc363fd082a3f63a446e8b5 +tool-catalog.zh.md: dc061b51681cc73f9d10059276bcdfa5b3268cff diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index a4441a1b6b..c60d3979e6 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -732,7 +732,7 @@ Source: [`packages/extensions/tool-cordis/src/index.ts`](../packages/extensions/ ### `cordis_inspect_query` -Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query an exact Slot root for its complete registration contract and props; an exact Factory root returns its identity, scope, and registrant. +Run a read-only query declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before writing plugin code to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. ```json { diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index 0a82afad88..dc061b5168 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -736,7 +736,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `cordis_inspect_query` -执行 Inspect Provider 明确声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。对于 Service.listService 和 Event.listEvents,不传 input 可浏览精简签名目录,再查询准确服务或事件以获得完整约定及引用类型。对于 Slots.listSubTree,不传 root 可浏览精简树;查询准确的 Slot root 可获得完整注册约定和 props,而查询准确的 Factory root 只返回 identity、scope 与 registrant。 +执行 Inspect Provider 声明的只读查询。platform、provider 和 method 必须来自 cordis_inspect_list,input 必须符合该方法的 schema。编写插件代码前,用本工具读取准确的 Service 方法、Event 模式、Builtin 签名、Tool schema、主题 token,或实时 Slot 树与 props。Host 查询在本地运行。Client 查询等待页面首个有效响应,直到页面回应或工具取消。本工具不能调用业务 Service 方法或修改运行时。 ```json { diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 0de9905f67..f2447b3546 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/README.i18n.yaml @@ -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/README.md -README.md: 79271f6f5f7a05ef4f6d5da63815dfe1ee5da3ef -README.zh.md: d98a0ef73a1b14a5eea98084cfb4ef224a7747a9 +README.md: 691a56856d0fceb62d3b32c3d5af90dd0323983a +README.zh.md: 9303fbaae7b0f9a10880c18a7af79f1dffb3bdeb diff --git a/packages/api/README.md b/packages/api/README.md index 79271f6f5f..691a56856d 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust |---|---|---| | [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | | [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index d98a0ef73a..9303fbaae7 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -28,6 +28,7 @@ kind: "package-group" |---|---|---| | [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | | [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` | | [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | | [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` | | [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml new file mode 100644 index 0000000000..31fcbbd285 --- /dev/null +++ b/packages/api/job-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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/job-controller/README.md +README.md: d3698758cc144c033037759a834944870948284d +README.zh.md: d9744b4d19cfff6d73923d0d26e3e817bc54c1a3 diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md new file mode 100644 index 0000000000..d3698758cc --- /dev/null +++ b/packages/api/job-controller/README.md @@ -0,0 +1,67 @@ +--- +description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor." +kind: "package-reference" +--- +# Job Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them. + +The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`. + +### Config + +| Field | Default | Meaning | +|---|---:|---| +| `observeFlushMs` | `100` | Coalescing window after a registry commit before the next rows or output read, in milliseconds | +| `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc. + +----- + + +## Model Experience + +None, as job observation is browser and Host control state; it registers no prompt, tool, or session event. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). + +#### KV Cache effect + +No direct effect; observation reads never touch model requests. + +## Known Limitations and Deferred Work + + + +- The ring is a best-effort live preview, not a terminal transcript: producers' pull sources are copied per poll round, so two streams' writes inside one poll window land stdout first, and the client concatenates chunks regardless of `channel`. +- Both streams are process-local: a Host restart loses every ring and roster; a resumed observation then anchors on an empty registry, and a re-opened roster starts empty. +- Per-session fencing is enforced by the caller argument of each registry read; the Remote layer itself serves any connected browser. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
+ +**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the event protocol and event-versus-read relations these streams forward. diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md new file mode 100644 index 0000000000..d9744b4d19 --- /dev/null +++ b/packages/api/job-controller/README.zh.md @@ -0,0 +1,67 @@ +--- +description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。" +kind: "package-reference" +--- +# Job Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。 + +Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。 + +### 配置 + +| 字段 | 默认值 | 含义 | +|---|---:|---| +| `observeFlushMs` | `100` | 注册表提交到下一次名册或输出读取之间的聚合窗口,毫秒 | +| `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。 + +----- + + +## 模型体验 + +无。job 观测是浏览器与 Host 的控制状态;它不注册任何提示词、工具或会话事件。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 + +#### KV 缓存影响 + +无直接影响;观测读取从不触碰模型请求。 + +## 已知限制与延期工作 + + + +- 环是尽力而为的实时预览,不是终端转录:生产者的拉取源按轮询轮次复制,同一轮询窗口内两条流的写入先 stdout 落地,客户端拼接 chunk 时也不区分 `channel`。 +- 两条流都是进程本地的:Host 重启丢失全部环与名册,续读的观测随后锚定在空注册表上,重开的名册从空开始。 +- 按 session 的访问限制由注册表每次读取时的调用方参数强制;Remote 层本身服务任何已连接浏览器。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
+ +**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;这些流转发的事件协议与事件对读取的关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。 diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json new file mode 100644 index 0000000000..30fcb07689 --- /dev/null +++ b/packages/api/job-controller/package.json @@ -0,0 +1,86 @@ +{ + "name": "@deepseek-ai/dsh-api-job-controller", + "description": "Job Remote observation stream and the reference-counted client job-output service", + "version": "0.1.6-alpha.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/job-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-jobs-local": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^" + } +} diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts new file mode 100644 index 0000000000..8c4f161ea2 --- /dev/null +++ b/packages/api/job-controller/src/client/index.ts @@ -0,0 +1,36 @@ +/** + * Job Controller client half: installs `ctx.jobs` over the generated `job` + * Remote namespace. The plugin resolves both Remote faces it drives while its + * own context is current, because stream (re)opens run on caller stacks — a + * React event, a carrier retry — whose dynamic context has not declared + * `remote.job`. + * @module @deepseek-ai/dsh-api-job-controller/client + */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-job-controller/remote' +import { ClientJobsModel } from './model.ts' +import { ClientJobs } from './service.ts' + +export type { JobsSnapshot, ObservedJob } from './model.ts' +// The `ctx.jobs` contract. Its module also carries the Context augmentation +// that declares `ctx.jobs`, which reaches consumers only through this export: +// declaration emit drops the value import above. +export type { IJobs } from './service.ts' +export type { + JobChunk, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest, JobView, +} from '../types.ts' + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.job'] + +/** + * Install the client jobs service. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + // Read the namespace now, not inside `open`: see the module JSDoc. + const { remote } = ctx + const { job } = remote + new ClientJobs(ctx, { $stream: options => remote.$stream(options), job }, new ClientJobsModel()) +} diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts new file mode 100644 index 0000000000..6f87a5d39e --- /dev/null +++ b/packages/api/job-controller/src/client/model.ts @@ -0,0 +1,216 @@ +/** + * React-free client job state: the roster each watched session can see, fed + * by `job.list` frames, and per-job accumulated output views fed by + * `job.follow` frames. Pure data plus subscriptions — transport wiring stays + * in the client service, UI stays in slot components. + * @module @deepseek-ai/dsh-api-job-controller/client/model + */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobView } from '@deepseek-ai/dsh-jobs/view' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobFollowFrame } from '../types.ts' + +/** Bounded per-job render tail, in UTF-16 code units. */ +const RENDER_TAIL_LIMIT = 128 * 1024 + +/** One observed job's live view state. */ +export interface ObservedJob { + readonly jobId: JobId + /** Accumulated output tail, bounded to the render limit. */ + readonly text: string + /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */ + readonly gapBefore: boolean + /** True while the observation stream is open and the job has not settled. */ + readonly streaming: boolean + /** Terminal observation failure, when the stream ended abnormally. */ + readonly error?: string +} + +/** Immutable client job state. */ +export interface JobsSnapshot { + /** + * The jobs each watched session can see, keyed by session id. A session + * nobody watches, or one that sees no job, has no key, so consumers read + * absence rather than a sentinel. + */ + readonly rows: Readonly> + /** Live observation state keyed by job id. */ + readonly observed: Readonly> +} + +/** Bare observable source for the client job snapshot. */ +export interface JobsSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): JobsSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */ +interface ObservedState { + view: ObservedJob + /** Resume offset for the next generation's `from`. */ + cursor: number | undefined +} + +/** Owns the per-session rosters and per-job observation state. */ +export class ClientJobsModel implements JobsSource { + private readonly rowsBySession = new Map() + private readonly observedStates = new Map() + private readonly listeners = new Set<() => void>() + private snapshotCache: JobsSnapshot = { rows: {}, observed: {} } + private snapshotDirty = false + + getSnapshot(): JobsSnapshot { + if (this.snapshotDirty) { + const rows: Record = {} + for (const [id, jobs] of this.rowsBySession) rows[id] = jobs + const observed: Record = {} + for (const [id, state] of this.observedStates) observed[id] = state.view + this.snapshotCache = { rows, observed } + this.snapshotDirty = false + } + return this.snapshotCache + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace one session's roster with a `rows` frame's whole set. An empty + * set is stored as an absent key. + * @param sessionId - the watched session. + * @param jobs - the complete visible set. + */ + rowsReplaced(sessionId: SessionId, jobs: readonly JobView[]): void { + const key = String(sessionId) + if (jobs.length === 0) { + if (!this.rowsBySession.delete(key)) return + } else { + this.rowsBySession.set(key, jobs) + } + this.changed() + } + + /** + * Drop one session's roster after its last watcher stops or its stream fails. + * @param sessionId - the no-longer-watched session. + */ + rowsDropped(sessionId: SessionId): void { + if (!this.rowsBySession.delete(String(sessionId))) return + this.changed() + } + + /** + * The resume offset for one job's next observation generation. + * @param id - observed job. + * @returns the last accepted `next`, or undefined for a fresh observation. + */ + cursorOf(id: JobId): number | undefined { + return this.observedStates.get(String(id))?.cursor + } + + /** + * Install or reset observation state when a generation's anchor arrives. + * @param id - observed job. + * @param frame - the generation's `opened` anchor. + */ + observeOpened(id: JobId, frame: Extract): void { + const existing = this.observedStates.get(String(id)) + // A fresh view anchored past offset zero starts after an evicted head + // (fresh observations anchor at the registry's earliest retained byte), so + // it owes the same gap mark a live observer earned from lossy reads. A + // resume that already accumulated text keeps its recorded gap state. + const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0 + const view: ObservedJob = { + jobId: id, + text: existing?.view.text ?? '', + gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.job.output.earliest || freshPastHead, + streaming: true, + } + this.observedStates.set(String(id), { view, cursor: frame.from }) + this.changed() + } + + /** + * Append one output frame's chunks to the bounded render tail. + * @param id - observed job. + * @param frame - a coalesced `output` frame. + */ + observeOutput(id: JobId, frame: Extract): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('') + let gapBefore = state.view.gapBefore || frame.lossy === true + || frame.chunks.some(chunk => chunk.gapBefore === true) + if (text.length > RENDER_TAIL_LIMIT) { + let cut = text.length - RENDER_TAIL_LIMIT + // Never split a surrogate pair at the render bound. + const unit = text.charCodeAt(cut) + if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1 + text = text.slice(cut) + gapBefore = true + } + state.view = { ...state.view, text, gapBefore } + state.cursor = frame.next + this.changed() + } + + /** + * Close the live view once the terminal `status` frame arrived: the ring is + * drained and the roster row carries the settled projection. + * @param id - observed job. + */ + observeSettled(id: JobId): void { + const state = this.observedStates.get(String(id)) + /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */ + if (state === undefined) return + state.view = { ...state.view, streaming: false } + this.changed() + } + + /** + * Record a terminal observation failure. + * @param id - observed job. + * @param error - the stream's terminal failure. + */ + observeFailed(id: JobId, error: unknown): void { + const state = this.observedStates.get(String(id)) + if (state === undefined) { + // A failure before the anchor — a rejected request, a job gone between + // the click and the open — still owes the panel its notice; a later + // successful anchor replaces this view and resumes from no cursor. + this.observedStates.set(String(id), { + view: { jobId: id, text: '', gapBefore: false, streaming: false, error: String(error) }, + cursor: undefined, + }) + this.changed() + return + } + state.view = { ...state.view, streaming: false, error: String(error) } + this.changed() + } + + /** + * Drop observation state after the last observer stops. + * @param id - the no-longer-observed job. + */ + observeStopped(id: JobId): void { + if (!this.observedStates.delete(String(id))) return + this.changed() + } + + private changed(): void { + this.snapshotDirty = true + notifySubscribers(this.listeners, 'jobs') + } +} diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts new file mode 100644 index 0000000000..e3eb06a623 --- /dev/null +++ b/packages/api/job-controller/src/client/service.ts @@ -0,0 +1,257 @@ +/** + * The `ctx.jobs` client service: reference-counted streams over the `job` + * namespace — one `job.list` roster stream per watched session and one + * `job.follow` stream per observed job — so overlapping viewers share a + * stream, rosters resume whole after a reconnect, and observations resume + * from the model's cursor. + * @module @deepseek-ai/dsh-api-job-controller/client/service + */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from '../types.ts' +import type { ClientJobsModel, JobsSource } from './model.ts' + +/** The generated `job` namespace face the stream runners drive. */ +export interface JobRemote { + /** + * Open one roster generation. + * @param request - the session whose visible set to mirror. + * @param signal - generation cancellation. + * @returns the whole-set frame sequence of one generation. + */ + list(request: JobListRequest, signal?: AbortSignal): AsyncIterable + /** + * Open one observation generation. + * @param request - target job, owning session, and optional resume offset. + * @param signal - generation cancellation. + * @returns the frame sequence of one generation. + */ + follow(request: JobFollowRequest, signal?: AbortSignal): AsyncIterable +} + +/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */ +export interface JobsRemote { + readonly $stream: ClientRemote['$stream'] + readonly job: JobRemote +} + +/** The client jobs service face. */ +export interface IJobs { + /** Rosters and per-job observation state. */ + readonly state: JobsSource + /** + * Keep one session's roster current; reference-counted, so two watchers of + * the same session share one stream and the rows leave with the last. + * @param sessionId - the session whose visible jobs to mirror. + * @returns stop function releasing this watcher's reference. + */ + watchRows(sessionId: SessionId): () => void + /** + * Start observing one job's live output; reference-counted, so two viewers + * of the same job share one stream. + * @param sessionId - owning session used for the fenced read; undefined for an unowned job. + * @param id - job to observe. + * @returns stop function releasing this observer's reference. + */ + observe(sessionId: SessionId | undefined, id: JobId): () => void +} + +/** One reference-counted stream. */ +interface StreamEntry { + refs: number + stopped: boolean + dispose: () => Promise +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free client job rosters and observation control. */ + jobs: IJobs + } +} + +/** Owns the bare jobs snapshot and the per-session and per-job streams. */ +export class ClientJobs extends Service implements IJobs { + readonly state: JobsSource + private readonly rowsEntries = new Map() + private readonly observations = new Map() + + /** + * @param ctx - client root Context. + * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller. + * @param model - shared client jobs model. + */ + constructor( + ctx: Context, + private readonly remote: JobsRemote, + private readonly model: ClientJobsModel, + ) { + super(ctx, 'jobs') + this.state = model + ctx.effect(() => async () => { + const open = [...this.rowsEntries.values(), ...this.observations.values()] + this.rowsEntries.clear() + this.observations.clear() + for (const entry of open) entry.stopped = true + // Cordis awaits an async disposer, so the fiber stays unloading until + // every carrier iterator has closed and a successor plugin instance + // cannot overlap one. A carrier whose teardown fails is stopped all the + // same; its failure has no consumer here. + await Promise.allSettled(open.map(entry => entry.dispose())) + }, 'job-controller.client.streams') + } + + watchRows(sessionId: SessionId): () => void { + return this.acquire( + this.rowsEntries, + String(sessionId), + () => this.startRows(sessionId), + () => { this.model.rowsDropped(sessionId) }, + ) + } + + observe(sessionId: SessionId | undefined, id: JobId): () => void { + return this.acquire( + this.observations, + String(id), + () => this.startObservation(sessionId, id), + () => { this.model.observeStopped(id) }, + ) + } + + /** Share the live entry under `key` or start one, and hand back its release. */ + private acquire( + entries: Map, + key: string, + start: () => StreamEntry, + cleared: () => void, + ): () => void { + const existing = entries.get(key) + if (existing !== undefined && !existing.stopped) { + existing.refs += 1 + return this.releaser(entries, key, existing, cleared) + } + const entry = start() + entries.set(key, entry) + return this.releaser(entries, key, entry, cleared) + } + + /** + * Release closures bind the exact entry they were minted for, never the + * map's current occupant: a later acquire on the same key may have replaced + * a stopped entry, and decrementing or disposing through the key alone + * would tear down that newer stream's references. + */ + private releaser(entries: Map, key: string, entry: StreamEntry, cleared: () => void): () => void { + let released = false + return () => { + if (released) return + released = true + entry.refs -= 1 + if (entry.refs > 0) return + if (entries.get(key) === entry) entries.delete(key) + entry.stopped = true + void entry.dispose().then(() => { + // Clear the state only while no successor holds the key: a re-acquire + // inside the dispose round-trip already refilled the model, and a + // stale clear would blank it for good. + if (entries.has(key)) return + cleared() + }) + } + } + + private startRows(sessionId: SessionId): StreamEntry { + const name = `job rows ${String(sessionId)}` + const stream = this.remote.$stream({ + name, + open: signal => this.remote.job.list({ sessionId }, signal), + // The roster has no natural end while it is watched: an end after the + // first frame is a carrier interruption (a Host reload closes the + // generation) and the next generation's whole set loses nothing. An end + // before the first frame is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before release`) + : new Error(`${name} ended before its first frame`), + }) + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + this.model.rowsReplaced(sessionId, item.value.jobs) + item.accept() + } + } catch { + // A terminal stream failure leaves nothing current to show; the model + // drops the roster rather than keeping a stale set on screen. + if (!entry.stopped) this.model.rowsDropped(sessionId) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } + + private startObservation(sessionId: SessionId | undefined, id: JobId): StreamEntry { + const name = `job observation ${String(id)}` + const stream = this.remote.$stream({ + name, + open: (signal) => { + const from = this.model.cursorOf(id) + return this.remote.job.follow( + { + jobId: id, + ...sessionId !== undefined ? { sessionId } : {}, + ...from !== undefined ? { from } : {}, + }, + signal, + ) + }, + // A premature end after the anchor is retryable (a Host reload closes the + // generation); resuming from the cursor loses nothing. An end before the + // anchor is terminal. + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${name} ended before settlement`) + : new Error(`${name} ended before its anchor`), + }) + const entry: StreamEntry = { + refs: 1, + stopped: false, + dispose: () => stream.dispose(), + } + void (async () => { + try { + for await (const item of stream) { + const frame = item.value + if (frame.type === 'opened') { + this.model.observeOpened(id, frame) + item.accept() + continue + } + if (frame.type === 'output') { + this.model.observeOutput(id, frame) + continue + } + // Terminal status: leave the loop before the generation end is + // classified, then close the stream for good. + this.model.observeSettled(id) + break + } + } catch (error) { + if (!entry.stopped) this.model.observeFailed(id, error) + } finally { + entry.stopped = true + void entry.dispose() + } + })() + return entry + } +} diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts new file mode 100644 index 0000000000..3c463dd85c --- /dev/null +++ b/packages/api/job-controller/src/index.ts @@ -0,0 +1,97 @@ +/** + * Host job Remote owner: streams the background-job roster one session can + * see and one job's retained output to browsers over the generated `job` + * namespace. Both streams are projections of `ctx.jobs`; the model's + * consuming cursor and notice state never observe them. + * @module @deepseek-ai/dsh-api-job-controller + */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/dsh-jobs' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { observeJobOutput } from './observe.ts' +import { streamJobRows } from './rows.ts' +import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host job Remote namespace owner. */ + jobController: JobController + } +} + +/** Default coalescing window between reads, in milliseconds. */ +const DEFAULT_OBSERVE_FLUSH_MS = 100 + +/** Default soft byte budget per output frame. */ +const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024 + +/** Job Controller deployment policy. */ +export interface Config { + /** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */ + readonly observeFlushMs?: number + /** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */ + readonly observeMaxFrameBytes?: number +} + +/** Host service backing the generated `ctx.remote.job` namespace. */ +export class JobController extends TypertRemoteService { + static inject = ['jobs', 'typert'] + + static Config: z = z.object({ + observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS), + observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES), + }) + + private readonly observeFlushMs: number + private readonly observeMaxFrameBytes: number + + /** + * @param ctx - Host context carrying the job registry. + * @param config - observation cadence and framing policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'jobController', { namespace: 'job' }) + // schemastery (the exported Config schema) has already filled the defaulted + // fields; the assertion records that resolution, not a hidden fallback. + const resolved = config as Required + this.observeFlushMs = resolved.observeFlushMs + this.observeMaxFrameBytes = resolved.observeMaxFrameBytes + } + + /** + * Stream the jobs one session can see — its own plus every unowned job — + * as whole-set frames: one on open, then one after each coalesced burst of + * lifecycle commits. The stream has no natural end; the carrier closes it. + * @param request - the session whose visible set to mirror. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns the roster frames. + */ + @Remote({ mode: 'stream' }) + list(request: JobListRequest, signal: AbortSignal): AsyncIterable { + return streamJobRows(this.ctx.jobs, request, { flushMs: this.observeFlushMs }, signal) + } + + /** + * Stream one job's retained output from an absolute byte offset, then its + * terminal projection once settled and drained. Non-consuming: the + * model-facing cursor and notice state never observe these reads. The + * request's session is the fenced read's caller; the registry rejects a + * job the session cannot see and an unknown job. + * @param request - target job, owning session, and optional resume offset. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns anchor, coalesced output frames, and the terminal status. + */ + @Remote({ mode: 'stream' }) + follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable { + return observeJobOutput(this.ctx.jobs, request, { + flushMs: this.observeFlushMs, + maxFrameBytes: this.observeMaxFrameBytes, + }, signal) + } +} + +export default JobController diff --git a/packages/api/job-controller/src/observe.ts b/packages/api/job-controller/src/observe.ts new file mode 100644 index 0000000000..31b4682bed --- /dev/null +++ b/packages/api/job-controller/src/observe.ts @@ -0,0 +1,132 @@ +/** Per-job observation generations: anchor, coalesced output, terminal status. */ + +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobChunk, JobRegistry, JobStatus, JobView } from '@deepseek-ai/dsh-jobs' +import type { JobFollowFrame, JobFollowRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' + +/** Cadence and framing bounds for one observation generation. */ +export interface ObserveJobOptions { + /** Coalescing window after a wake before reading, in milliseconds. */ + readonly flushMs: number + /** + * Soft byte budget per `output` frame. Accumulated chunks flush once the + * budget is met; one chunk larger than the budget ships whole (the + * registry's retention cap is the hard bound on any single chunk). + */ + readonly maxFrameBytes: number +} + +function isTerminal(status: JobStatus): boolean { + return status !== 'running' && status !== 'stopping' +} + +/** + * Stream one job's retained output from an absolute offset: one `opened` + * anchor, coalesced `output` frames as the ring advances, then one terminal + * `status` after the settled job is drained, after which the generation + * closes normally. A removal announced mid-generation (the owner's teardown) + * closes it with the removed job's terminal projection instead of a failed + * read. Reads are non-consuming — the model-facing cursor and + * notice state never observe them; reconnecting callers resume by passing + * the last frame's `next` as `from`. The request's session is the fenced + * read's caller: the registry rejects a job the session cannot see and an + * unknown job. + * @param registry - the live job registry. + * @param request - target job, owning session, and optional resume offset. + * @param options - cadence and framing bounds. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the observation frame sequence for one generation. + */ +export async function* observeJobOutput( + registry: JobRegistry, + request: JobFollowRequest, + options: ObserveJobOptions, + signal: AbortSignal, +): AsyncIterable { + if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) { + throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`) + } + signal.throwIfAborted() + // The brand is nominal typing only; the wire boundary stamps it here rather + // than value-importing the registry package's constructor. + const id = String(request.jobId) as JobId + const waiter = new OutputWaiter() + // A removal announced during the generation: the owner's teardown settled + // and dropped the record, so the projection it announced is the last word + // and the read after the flush window would only find an unknown id. + let removed: JobView | undefined + // Subscribe before the first read so an append between the anchor read and + // the wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + const changed = event.type === 'output' ? event.id : event.job.id + if (changed !== id) return + if (event.type === 'removed') removed = event.job + waiter.wake() + }) + try { + let job = registry.get(id, request.sessionId) + let cursor = request.from ?? job.output.earliest + yield { type: 'opened', job, from: cursor } + while (!signal.aborted) { + if (removed !== undefined) { + yield { type: 'status', job: removed } + return + } + const read = registry.readAt(id, cursor, request.sessionId) + if (read.chunks.length > 0 || read.lossy) { + yield* outputFrames(read.chunks, read.next, read.lossy, options.maxFrameBytes) + } + cursor = read.next + job = registry.get(id, request.sessionId) + if (isTerminal(job.status) && cursor >= job.output.total) { + yield { type: 'status', job } + return + } + await waiter.wait(signal) + // Let a burst accumulate so producer chatter becomes bounded frames. + await sleep(options.flushMs, signal) + } + } finally { + unsubscribe() + } +} + +/** Split one read into frames along the soft per-frame byte budget. */ +function* outputFrames( + chunks: readonly JobChunk[], + next: number, + lossy: boolean, + maxFrameBytes: number, +): Iterable { + let batch: JobChunk[] = [] + let batchBytes = 0 + let flaggedLossy = lossy + for (const chunk of chunks) { + batch.push(chunk) + batchBytes += Buffer.byteLength(chunk.text, 'utf8') + if (batchBytes >= maxFrameBytes) { + const last = batch[batch.length - 1] + /* v8 ignore start -- a non-empty batch always has a last chunk; the arm only discharges noUncheckedIndexedAccess. */ + const end = last === undefined ? next : last.at + Buffer.byteLength(last.text, 'utf8') + /* v8 ignore stop */ + yield { + type: 'output', + chunks: batch, + next: end, + ...flaggedLossy ? { lossy: true as const } : {}, + } + flaggedLossy = false + batch = [] + batchBytes = 0 + } + } + if (batch.length > 0 || flaggedLossy) { + yield { + type: 'output', + chunks: batch, + next, + ...flaggedLossy ? { lossy: true as const } : {}, + } + } +} diff --git a/packages/api/job-controller/src/rows.ts b/packages/api/job-controller/src/rows.ts new file mode 100644 index 0000000000..9ef6564979 --- /dev/null +++ b/packages/api/job-controller/src/rows.ts @@ -0,0 +1,53 @@ +/** Per-session roster generations: the caller-visible job set, replaced whole after every lifecycle change. */ + +import type { JobRegistry } from '@deepseek-ai/dsh-jobs' +import type { JobListFrame, JobListRequest } from './types.ts' +import { OutputWaiter, sleep } from './wake.ts' + +/** Cadence bound for one roster generation. */ +export interface RowsOptions { + /** Coalescing window after a lifecycle event before the next rows read, in milliseconds. */ + readonly flushMs: number +} + +/** + * Stream the jobs one session can see: one frame on open, then one after + * every lifecycle commit that touches a visible job (registration, progress, + * stopping, settlement, removal), coalesced over `flushMs`. Output appends + * never refresh the roster — a settled projection already carries the final + * byte count — so the stream is quiet while a job merely writes. Reads are + * projections; the model's cursor and notice state never observe them. + * @param registry - the live job registry. + * @param request - the session whose visible set to mirror. + * @param options - the coalescing window. + * @param signal - generation cancellation owned by the Remote stream carrier. + * @returns the roster frame sequence for one generation. + */ +export async function* streamJobRows( + registry: JobRegistry, + request: JobListRequest, + options: RowsOptions, + signal: AbortSignal, +): AsyncIterable { + signal.throwIfAborted() + const waiter = new OutputWaiter() + // Subscribe before the first read so a commit between the read and the + // wait cannot be missed. + const unsubscribe = registry.events.subscribe({ owners: 'all' }, (event) => { + if (event.type === 'output') return + const owner = event.job.owner + if (owner === undefined || owner === request.sessionId) waiter.wake() + }) + try { + yield { type: 'rows', jobs: registry.list(request.sessionId) } + while (true) { + await waiter.wait(signal) + // Let a burst of commits settle into one frame. + await sleep(options.flushMs, signal) + if (signal.aborted) return + yield { type: 'rows', jobs: registry.list(request.sessionId) } + } + } finally { + unsubscribe() + } +} diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts new file mode 100644 index 0000000000..aa02d2061f --- /dev/null +++ b/packages/api/job-controller/src/types.ts @@ -0,0 +1,70 @@ +/** + * Wire types of the generated `job` Remote namespace: the per-session roster + * stream and the per-job observation stream. Client-safe: no Host imports. + * @module @deepseek-ai/dsh-api-job-controller/types + */ + +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +export type { JobChunk, JobView } from '@deepseek-ai/dsh-jobs/view' + +/** Target of one `job.list` stream: the session whose visible jobs the stream mirrors. */ +export interface JobListRequest { + readonly sessionId: SessionId +} + +/** + * One `job.list` frame: the complete set the session can see — its own jobs + * plus every unowned job — after a lifecycle change. Whole-set replacement, + * so a reconnect's first frame is already the truth. + */ +export interface JobListFrame { + readonly type: 'rows' + readonly jobs: readonly JobView[] +} + +/** Target of one `job.follow` stream: the job, its owning session, and an optional resume offset. */ +export interface JobFollowRequest { + /** + * Owning session for the fenced read. Omitted for an unowned job, which any + * caller may observe. + */ + readonly sessionId?: SessionId + readonly jobId: JobId + /** + * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the + * stream starts at the oldest retained byte. + */ + readonly from?: number +} + +/** + * Job observation stream frames: one `opened` anchor carrying the job's + * projection (its `output.earliest` and `output.total` at open time) and the + * offset the first `output` frame continues from, then coalesced `output` + * batches, then — once the job has settled and its ring is drained — one + * terminal `status` carrying the settled projection, after which the stream + * closes normally. Status rides the same stream as output so settlement can + * never race a still-open output channel. + */ +export type JobFollowFrame = + | { + readonly type: 'opened' + readonly job: JobView + /** Offset the first `output` frame continues from. */ + readonly from: number + } + | { + readonly type: 'output' + readonly chunks: readonly JobChunk[] + /** Offset to resume from after this frame. */ + readonly next: number + /** Bytes between the requested offset and `chunks` were already evicted. */ + readonly lossy?: true + } + | { + readonly type: 'status' + readonly job: JobView + } diff --git a/packages/api/job-controller/src/wake.ts b/packages/api/job-controller/src/wake.ts new file mode 100644 index 0000000000..489699cb3c --- /dev/null +++ b/packages/api/job-controller/src/wake.ts @@ -0,0 +1,60 @@ +/** + * Wake bookkeeping shared by the two observation generators: a wake-flag + * waiter that never loses a wake between waits, and an abortable sleep that + * coalesces bursts into bounded frames. + * @module @deepseek-ai/dsh-api-job-controller/wake + */ + +/** Wake-flag waiter: a wake between waits is never lost. */ +export class OutputWaiter { + private dirty = false + private resolve: (() => void) | undefined + + /** Record one wake; releases a pending wait or arms the next one. */ + wake(): void { + this.dirty = true + this.resolve?.() + } + + /** + * Resolve on the next wake, immediately when one already arrived, or on abort. + * @param signal - generation cancellation. + * @returns settles when woken or aborted. + */ + wait(signal: AbortSignal): Promise { + if (this.dirty || signal.aborted) { + this.dirty = false + return Promise.resolve() + } + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one wait owns the sole installed resolver. */ + if (this.resolve === finish) this.resolve = undefined + this.dirty = false + resolve() + } + this.resolve = finish + signal.addEventListener('abort', finish, { once: true }) + }) + } +} + +/** + * Sleep for the coalescing window, or return at once when aborted. + * @param ms - window in milliseconds. + * @param signal - generation cancellation. + * @returns settles after the window or on abort. + */ +export function sleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve() + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + signal.addEventListener('abort', done, { once: true }) + }) +} diff --git a/packages/api/job-controller/tests/built-lib.e2e.ts b/packages/api/job-controller/tests/built-lib.e2e.ts new file mode 100644 index 0000000000..e0d45bdce3 --- /dev/null +++ b/packages/api/job-controller/tests/built-lib.e2e.ts @@ -0,0 +1,42 @@ +import { existsSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { describe, expect, it } from 'vitest' + +/** + * Built-artifact smoke for the generated job Remote codecs. This package's + * program compiles only the `JobKindMap` merges it can see (`bash`, + * `subagent`), while the Host registers `pwsh`, `pty-send`, and every other + * producer kind, so the wire projection's `kind` must reach the codec as an + * open string rather than the literal union this program observes. + */ + +const artifact = join(fileURLToPath(new URL('..', import.meta.url)), 'lib/typert.host.js') + +interface BuiltInvocation { + readonly method: string + readonly result: { create(): { safeParse(value: unknown): { success: boolean } } } +} + +const row = { + id: 'pty-send-1', + kind: 'pty-send', + label: 'send', + status: 'running', + startedAt: 1, + output: { total: 0, earliest: 0 }, +} + +describe.skipIf(!existsSync(artifact))('job Remote built codecs', () => { + it('accept a row whose kind this program never compiled, and still reject a status outside the lifecycle', async () => { + const { TYPERT } = await import(pathToFileURL(artifact).href) as { TYPERT: { invocations: readonly BuiltInvocation[] } } + const schemaOf = (method: string): ReturnType => { + const invocation = TYPERT.invocations.find(candidate => candidate.method === method) + if (invocation === undefined) throw new Error(`no generated job.${method} invocation`) + return invocation.result.create() + } + expect(schemaOf('list').safeParse({ type: 'rows', jobs: [row] }).success).toBe(true) + expect(schemaOf('follow').safeParse({ type: 'opened', job: row, from: 0 }).success).toBe(true) + expect(schemaOf('follow').safeParse({ type: 'status', job: { ...row, status: 'paused' } }).success).toBe(false) + }) +}) diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..0dd43b6f41 --- /dev/null +++ b/packages/api/job-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,73 @@ +import { Context } from '@deepseek-ai/cordis' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { afterEach, describe, expect, it } from 'vitest' +import * as JobClient from '../src/client/index.ts' +import type { JobFollowFrame, JobListFrame } from '../src/types.ts' + +const contexts = new Set() + +afterEach(async () => { + await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) + contexts.clear() +}) + +/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */ +async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[] }> { + const ctx = new Context() + contexts.add(ctx) + const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] + const connection: ConnectionHandle = { + isLoopback: true, + generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} }, + state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} }, + rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) }, + reconnect: () => {}, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + const job = { + // The fake streams never yield; opening and releasing must leave no state behind. + list: (request: unknown) => { + rowsCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, + follow: (request: unknown) => { + observeCalls.push(request) + return (async function* (): AsyncGenerator {})() + }, + } + ctx.reflect.provide('remote', { + $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), + job, + }) + ctx.reflect.provide('remote.job', job) + await ctx.plugin(JobClient) + return { ctx, observeCalls, rowsCalls } +} + +async function flush(): Promise { + for (let index = 0; index < 12; index++) await Promise.resolve() +} + +describe('Job Controller Client apply', () => { + it('declares the Remote services it binds', () => { + expect(JobClient.inject).toEqual(['remote', 'remote.job']) + }) + + it('installs ctx.jobs over the captured job namespace', async () => { + const { ctx, observeCalls, rowsCalls } = await mount() + expect(ctx.jobs).toBeDefined() + const stopRows = ctx.jobs.watchRows('session-1' as SessionId) + const stopObserve = ctx.jobs.observe('session-1' as SessionId, 'bash-1' as never) + await flush() + expect(rowsCalls).toEqual([{ sessionId: 'session-1' }]) + expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }]) + stopRows() + stopObserve() + await flush() + expect(ctx.jobs.state.getSnapshot()).toEqual({ rows: {}, observed: {} }) + }) +}) diff --git a/packages/api/job-controller/tests/host-harness.ts b/packages/api/job-controller/tests/host-harness.ts new file mode 100644 index 0000000000..85609166b0 --- /dev/null +++ b/packages/api/job-controller/tests/host-harness.ts @@ -0,0 +1,101 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobAppendOptions, JobHandle, JobOutcome } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import { Session, SessionId } from '@deepseek-ai/dsh-session' + +/** A registry with a tiny ring so eviction is cheap to reach. */ +export async function harness(): Promise { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry, { retainBytes: 64, settledRetainBytes: 64 }) + ctx.jobs.attachController('job-controller-test') + return ctx +} + +/** Register a live agent for `rawId` under its own lifecycle scope. */ +export async function registerAgent(ctx: Context, rawId: string): Promise Promise }> { + const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(rawId) + const session = Session.create(id) + const agent: Agent & { disposeScope: () => Promise } = { + id, + options: {}, + session, + inbox: unsupportedInbox(), + status: 'idle', + ctx: scopeFiber.ctx, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + disposeScope: () => scopeFiber.dispose(), + } + await ctx.agents.register(agent) + return agent +} + +/** + * Start one push-producer job whose `done` settles on demand; expose the + * handle's writers. A cancel settles the job as killed unless `slowStop` + * keeps it stopping until the test settles it. + */ +export function startJob(ctx: Context, options: { label?: string; owner?: SessionId; slowStop?: boolean } = {}) { + let settle!: (outcome: JobOutcome) => void + let handle!: JobHandle + const id = ctx.jobs.start({ + kind: 'bash', + label: options.label ?? 'echo', + ...options.owner !== undefined ? { owner: options.owner } : {}, + run: (job) => { + handle = job + return { + cancel() { if (options.slowStop !== true) settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + } + }, + }) + return { + id, + append: (text: string, opts?: JobAppendOptions) => { handle.append(text, opts) }, + progress: (line: string) => { handle.updateProgress(line) }, + settle: async (outcome: JobOutcome) => { + settle(outcome) + await new Promise(resolve => setTimeout(resolve, 0)) + }, + } +} + +/** Read frames until `count` arrive or the generator finishes, then abort. */ +export async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: Frame[] = [] + const iterator = iterable[Symbol.asyncIterator]() + try { + while (frames.length < count) { + const step = await iterator.next() + if (step.done) return frames + frames.push(step.value) + } + } finally { + abort.abort() + await iterator.next().catch(() => undefined) + } + return frames +} + +export const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)) + +/** The next yielded frame, or undefined once the generator finished. */ +export async function next(iterator: AsyncIterator): Promise { + const step = await iterator.next() + return step.done === true ? undefined : step.value +} diff --git a/packages/api/job-controller/tests/model.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..1c56724a26 --- /dev/null +++ b/packages/api/job-controller/tests/model.client.spec.ts @@ -0,0 +1,567 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { ClientJobsModel } from '../src/client/model.ts' +import { ClientJobs } from '../src/client/service.ts' +import type { JobFollowFrame, JobListFrame, JobView } from '../src/types.ts' + +const ID = 'bash-1' as JobId +const S1 = 'alice' as SessionId +const S2 = 'bob' as SessionId + +function view(over: Partial = {}): JobView { + return { + id: ID, kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, + output: { total: 0, earliest: 0 }, + ...over, + } +} + +function opened(model: ClientJobsModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void { + model.observeOpened(ID, { + type: 'opened', + job: view({ output: { total: over.total ?? 0, earliest: over.earliest ?? 0 } }), + from: over.from ?? 0, + }) +} + +describe('ClientJobsModel rows', () => { + it('replaces a session roster whole, stores an empty set as absence, and drops on release', () => { + const model = new ClientJobsModel() + let notified = 0 + model.subscribe(() => { notified += 1 }) + model.rowsReplaced(S1, [view()]) + model.rowsReplaced(S2, [view({ id: 'pwsh-1' as JobId, label: 'other' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(model.getSnapshot().rows[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + model.rowsReplaced(S1, [view({ status: 'completed' })]) + expect(model.getSnapshot().rows[S1]).toEqual([view({ status: 'completed' })]) + expect(notified).toBe(3) + + model.rowsReplaced(S1, []) + expect(S1 in model.getSnapshot().rows).toBe(false) + // An empty set over an absent key changes nothing. + model.rowsReplaced(S1, []) + expect(notified).toBe(4) + + model.rowsDropped(S2) + expect(model.getSnapshot().rows).toEqual({}) + model.rowsDropped(S2) + expect(notified).toBe(5) + }) +}) + +describe('ClientJobsModel observation', () => { + it('accumulates output, advances the resume cursor, and closes on settlement', () => { + const model = new ClientJobsModel() + opened(model) + expect(model.cursorOf(ID)).toBe(0) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 }) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 }) + expect(model.cursorOf(ID)).toBe(3) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text).toBe('abc') + expect(live?.streaming).toBe(true) + model.observeSettled(ID) + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + }) + + it('keeps snapshot identity stable between changes and notifies subscribers', () => { + const model = new ClientJobsModel() + let notified = 0 + const unsubscribe = model.subscribe(() => { notified += 1 }) + const before = model.getSnapshot() + expect(model.getSnapshot()).toBe(before) + opened(model) + expect(notified).toBe(1) + expect(model.getSnapshot()).not.toBe(before) + unsubscribe() + model.observeStopped(ID) + expect(notified).toBe(1) + }) + + it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => { + const model = new ClientJobsModel() + opened(model, { from: 4, earliest: 8, total: 10 }) + expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const clean = new ClientJobsModel() + opened(clean) + clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 }) + expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + const lossy = new ClientJobsModel() + opened(lossy) + lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true }) + expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('marks a fresh observation anchored past the evicted head', () => { + // Fresh observations anchor at the registry's earliest retained byte, so + // from === earliest > 0 means the head was already discarded. + const fresh = new ClientJobsModel() + opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 }) + expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + + // A retry that accumulated no text yet earns the mark the same way. + const retried = new ClientJobsModel() + opened(retried) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false) + opened(retried, { from: 6, earliest: 6, total: 6 }) + expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true) + }) + + it('bounds the render tail without splitting a surrogate pair', () => { + const model = new ClientJobsModel() + opened(model) + const emoji = '😀'.repeat((64 * 1024) + 8) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 }) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.gapBefore).toBe(true) + expect(live!.text.length).toBeLessThanOrEqual(128 * 1024) + // The bound landed between pairs: the surviving text still round-trips. + expect(/^(?:😀)+$/u.test(live!.text)).toBe(true) + }) + + it('trims a plain-ASCII tail without a boundary shift', () => { + const model = new ClientJobsModel() + opened(model) + const long = 'x'.repeat((128 * 1024) + 5) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length }) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe(128 * 1024) + expect(live?.gapBefore).toBe(true) + }) + + it('advances the cut past a low surrogate landing exactly on the bound', () => { + const model = new ClientJobsModel() + opened(model) + // 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index. + const text = 'z😀' + 'a'.repeat((128 * 1024) - 1) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length }) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.text.length).toBe((128 * 1024) - 1) + expect(live?.text.startsWith('a')).toBe(true) + }) + + it('preserves accumulated text across a reconnect anchor and clears on stop', () => { + const model = new ClientJobsModel() + opened(model) + model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 }) + opened(model, { from: 4, earliest: 0, total: 4 }) + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept') + model.observeStopped(ID) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + expect(model.cursorOf(ID)).toBeUndefined() + // A second stop is inert. + model.observeStopped(ID) + }) + + it('records a terminal stream failure on the live view, or on a fresh view before the anchor', () => { + const model = new ClientJobsModel() + opened(model) + model.observeFailed(ID, new Error('carrier gone')) + const live = model.getSnapshot().observed[String(ID)] + expect(live?.streaming).toBe(false) + expect(live?.error).toContain('carrier gone') + // A failure before any anchor still surfaces: the panel gets the notice on + // an empty, non-streaming view, and a retry anchors from no cursor. + model.observeFailed('bash-9' as JobId, new Error('ended before its anchor')) + const early = model.getSnapshot().observed['bash-9'] + expect(early).toMatchObject({ jobId: 'bash-9', text: '', gapBefore: false, streaming: false }) + expect(early?.error).toContain('ended before its anchor') + expect(model.cursorOf('bash-9' as JobId)).toBeUndefined() + }) +}) + +/** One scripted logical stream: frames are pushed by the test, never reopened. */ +class FakeStream { + disposed = false + /** Model a carrier whose disposal surfaces as an iterator throw. */ + throwOnDispose = false + /** Hold the dispose promise open until {@link releaseDispose}. */ + deferDispose = false + private disposeRelease: (() => void) | undefined + private disposePending: Promise | undefined + private readonly frames: Frame[] = [] + private waiter: (() => void) | undefined + private failure: Error | undefined + readonly accepts: number[] = [] + + push(frame: Frame): void { + this.frames.push(frame) + this.waiter?.() + } + + poison(error: Error): void { + this.failure = error + this.waiter?.() + } + + dispose(): Promise { + if (this.throwOnDispose && this.failure === undefined) { + this.failure = new Error('carrier tore down') + } else { + this.disposed = true + } + this.waiter?.() + if (this.deferDispose) { + // Repeat disposals (releaser plus the consumer's finally) share one + // deferred promise, so releaseDispose resumes every waiter. + this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve }) + return this.disposePending + } + return Promise.resolve() + } + + releaseDispose(): void { + this.disposeRelease?.() + } + + async *[Symbol.asyncIterator]() { + let generation = 1 + while (!this.disposed) { + if (this.failure !== undefined) throw this.failure + const frame = this.frames.shift() + if (frame === undefined) { + await new Promise((resolve) => { this.waiter = resolve }) + continue + } + yield { + generation, + value: frame, + signal: new AbortController().signal, + accept: () => { this.accepts.push(generation) }, + } + generation = 1 + } + } +} + +interface StreamOptions { + name: string + open: (signal: AbortSignal) => unknown + ended: (accepted: boolean) => Error +} + +function bench() { + const ctx = new Context() + const model = new ClientJobsModel() + const streams: { options: StreamOptions; stream: FakeStream & FakeStream }[] = [] + const observeCalls: unknown[] = [] + const rowsCalls: unknown[] = [] + const remote = { + $stream: (options: StreamOptions) => { + const stream = new FakeStream() + streams.push({ options, stream }) + return stream + }, + job: { + follow: (request: unknown) => { + observeCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + list: (request: unknown) => { + rowsCalls.push(request) + return { [Symbol.asyncIterator]: async function* () { /* never yields */ } } + }, + }, + } + const jobs = new ClientJobs(ctx, remote as never, model) + return { ctx, model, jobs, streams, observeCalls, rowsCalls } +} + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) +const anchor = (): JobFollowFrame => ({ type: 'opened', job: view(), from: 0 }) + +describe('ClientJobs roster streams', () => { + it('shares one roster stream per session and drops the rows after the last release', async () => { + const { model, jobs, streams, rowsCalls } = bench() + const first = jobs.watchRows(S1) + const second = jobs.watchRows(S1) + jobs.watchRows(S2) + expect(streams.map(entry => entry.options.name)).toEqual(['job rows alice', 'job rows bob']) + streams[0]!.options.open(new AbortController().signal) + expect(rowsCalls.at(-1)).toEqual({ sessionId: 'alice' }) + + const { stream } = streams[0]! + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toEqual([view()]) + expect(stream.accepts).toHaveLength(1) + stream.push({ type: 'rows', jobs: [view({ status: 'completed' })] }) + await tick() + expect(model.getSnapshot().rows[S1]?.[0]?.status).toBe('completed') + + first() + first() + expect(stream.disposed).toBe(false) + second() + await tick() + expect(stream.disposed).toBe(true) + expect(S1 in model.getSnapshot().rows).toBe(false) + expect(streams[1]!.stream.disposed).toBe(false) + }) + + it('classifies a roster end as retryable only after the first frame', () => { + const { jobs, streams } = bench() + jobs.watchRows(S1) + const { options } = streams[0]! + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('drops the roster on a terminal stream failure', async () => { + const { model, jobs, streams } = bench() + jobs.watchRows(S1) + const { stream } = streams[0]! + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + expect(model.getSnapshot().rows[S1]).toBeDefined() + stream.poison(new Error('frame decode broke')) + await tick() + expect(S1 in model.getSnapshot().rows).toBe(false) + }) + + it('stays silent when the carrier teardown itself throws after the last release', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push({ type: 'rows', jobs: [view()] }) + await tick() + // The releaser drops the rows and marks the entry stopped, then its + // dispose surfaces as an iterator throw — which the consumer swallows for + // a stopped entry instead of dropping twice or failing loud. + stop() + await tick() + expect(model.getSnapshot().rows).toEqual({}) + }) + + it('a re-watch inside the dispose round-trip keeps its fresh rows', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.watchRows(S1) + const first = streams[0]!.stream + first.deferDispose = true + first.push({ type: 'rows', jobs: [view()] }) + await tick() + + stop() + // Re-watch while the previous generation's dispose is still in flight. + jobs.watchRows(S1) + const second = streams[1]!.stream + second.push({ type: 'rows', jobs: [view({ label: 'fresh' })] }) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's rows. + expect(model.getSnapshot().rows[S1]?.[0]?.label).toBe('fresh') + }) +}) + +describe('ClientJobs observation streams', () => { + it('feeds anchor, output, and terminal frames into the model, then closes', async () => { + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const { stream } = streams[0]! + expect(streams[0]!.options.name).toBe('job observation bash-1') + stream.push(anchor()) + stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi') + expect(stream.accepts).toHaveLength(1) + + stream.push({ type: 'status', job: view({ status: 'completed' }) }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false) + expect(stream.disposed).toBe(true) + }) + + it('shares one stream across observers and stops after the last release', async () => { + const { model, jobs, streams } = bench() + const first = jobs.observe(undefined, ID) + const second = jobs.observe(undefined, ID) + expect(streams).toHaveLength(1) + streams[0]!.stream.push(anchor()) + await tick() + + first() + first() + expect(streams[0]!.stream.disposed).toBe(false) + second() + await tick() + expect(streams[0]!.stream.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a late release of a superseded observation neither tears down nor clears its successor', async () => { + const { model, jobs, streams } = bench() + const firstStop = jobs.observe(undefined, ID) + const first = streams[0]!.stream + first.push(anchor()) + first.push({ type: 'status', job: view({ status: 'completed' }) }) + await tick() + // A successor observation replaces the settled entry while the first + // observer still holds its releaser. + const secondStop = jobs.observe(undefined, ID) + const second = streams[1]!.stream + second.push(anchor()) + await tick() + + firstStop() + await tick() + expect(second.disposed).toBe(false) + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + + secondStop() + await tick() + expect(second.disposed).toBe(true) + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + }) + + it('a re-observation inside the dispose round-trip keeps its fresh view', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) + const first = streams[0]!.stream + first.deferDispose = true + first.push(anchor()) + await tick() + + stop() + // Re-expand while the previous generation's dispose is still in flight. + jobs.observe(undefined, ID) + const second = streams[1]!.stream + second.push(anchor()) + await tick() + + first.releaseDispose() + await tick() + // The stale post-dispose clear must not blank the successor's view. + expect(model.getSnapshot().observed[String(ID)]).toBeDefined() + second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 }) + await tick() + expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive') + }) + + it('opens a fresh stream for a re-observed job and resumes from the model cursor', async () => { + const { jobs, streams, observeCalls, model } = bench() + const stop = jobs.observe(S1, ID) + streams[0]!.stream.push(anchor()) + streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 }) + await tick() + // The generation opener reads the live cursor at open time and carries the + // fenced-read session. + streams[0]!.options.open(new AbortController().signal) + expect(observeCalls.at(-1)).toEqual({ jobId: ID, sessionId: 'alice', from: 3 }) + + stop() + await tick() + jobs.observe(undefined, ID) + expect(streams).toHaveLength(2) + streams[1]!.options.open(new AbortController().signal) + // Stopped observation dropped the cursor: the fresh stream starts unanchored. + expect(observeCalls.at(-1)).toEqual({ jobId: ID }) + expect(model.cursorOf(ID)).toBeUndefined() + }) + + it('classifies a premature end as retryable only after the anchor', () => { + const { jobs, streams } = bench() + jobs.observe(undefined, ID) + const { options } = streams[0]! + expect(options.ended(true).name).toBe('RemoteStreamCarrierError') + expect(options.ended(false).name).toBe('Error') + }) + + it('records a consumer failure on the live view', async () => { + const { model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const { stream } = streams[0]! + stream.push(anchor()) + await tick() + stream.poison(new Error('frame decode broke')) + await tick() + const live = model.getSnapshot().observed[String(ID)] + expect(live?.error).toContain('frame decode broke') + expect(live?.streaming).toBe(false) + }) + + it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => { + const { ctx, model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) + const { stream } = streams[0]! + stream.push(anchor()) + await tick() + // Poison wakes the consumer, but the synchronous stop lands first, so the + // failure arrives on an already-stopped entry and stays silent. + stream.poison(new Error('after stop')) + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]).toBeUndefined() + + const second = jobs.observe(undefined, ID) + await ctx.fiber.dispose() + // The disposal path already removed every entry; a live releaser whose + // entry is gone is inert. + second() + }) + + it('stays silent when the carrier teardown itself throws after a stop', async () => { + const { model, jobs, streams } = bench() + const stop = jobs.observe(undefined, ID) + const { stream } = streams[0]! + stream.throwOnDispose = true + stream.push(anchor()) + await tick() + // The releaser marks the entry stopped, then its dispose surfaces as an + // iterator throw — which the consumer swallows for a stopped entry. + stop() + await tick() + expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined() + }) + + it('holds service disposal until every open stream reports quiescence', async () => { + const { ctx, jobs, streams } = bench() + jobs.watchRows(S1) + jobs.observe(undefined, ID) + for (const { stream } of streams) stream.deferDispose = true + await tick() + + let settled = false + const disposal = ctx.fiber.dispose().then(() => { settled = true }) + await tick() + // Both carriers were told to stop, but neither iterator has closed yet: + // the fiber must still be unloading, or a successor plugin instance could + // overlap the old generation's iterator. + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(settled).toBe(false) + + streams[0]!.stream.releaseDispose() + await tick() + expect(settled).toBe(false) + streams[1]!.stream.releaseDispose() + await disposal + expect(settled).toBe(true) + }) + + it('records a terminal stream failure and service disposal closes live streams', async () => { + const { ctx, model, jobs, streams } = bench() + jobs.observe(undefined, ID) + const failing = streams[0]!.stream + failing.push(anchor()) + await tick() + // Simulate a terminal consumer failure by disposing the underlying stream: + // the consumer loop ends without a status frame and cleans up. + const spy = vi.spyOn(model, 'observeFailed') + await failing.dispose() + await tick() + + jobs.observe(undefined, 'bash-2' as JobId) + await ctx.fiber.dispose() + expect(streams.every(entry => entry.stream.disposed)).toBe(true) + expect(spy).not.toHaveBeenCalled() + }) +}) diff --git a/packages/api/job-controller/tests/observe.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts new file mode 100644 index 0000000000..583862eeac --- /dev/null +++ b/packages/api/job-controller/tests/observe.host.spec.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { JobId } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { JobController } from '../src/index.ts' +import { observeJobOutput } from '../src/observe.ts' +import type { ObserveJobOptions } from '../src/observe.ts' +import type { JobFollowFrame } from '../src/types.ts' +import { collect, harness, next, registerAgent, startJob, wait } from './host-harness.ts' + +const OBSERVE: ObserveJobOptions = { flushMs: 5, maxFrameBytes: 1024 } + +describe('observeJobOutput', () => { + function observed(ctx: Context, request: { jobId: JobId; sessionId?: SessionId; from?: number }) { + const abort = new AbortController() + const frames: JobFollowFrame[] = [] + const stream = observeJobOutput(ctx.jobs, request, OBSERVE, abort.signal) + const done = (async () => { + for await (const frame of stream) frames.push(frame) + })() + return { abort, frames, done } + } + + it('anchors, streams coalesced output, and closes with the terminal projection', async () => { + const ctx = await harness() + const job = startJob(ctx) + job.append('early ') + const { frames, done } = observed(ctx, { jobId: job.id }) + await wait(20) + job.append('live', { channel: 'stderr' }) + await wait(20) + await job.settle({ status: 'completed', detail: 'exit code: 0' }) + await done + + expect(frames[0]).toMatchObject({ + type: 'opened', + from: 0, + job: { id: job.id, status: 'running', output: { earliest: 0, total: 6 } }, + }) + const outputs = frames.filter(frame => frame.type === 'output') + const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('') + expect(text).toBe('early live') + expect(outputs.at(-1)?.next).toBe(10) + expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr') + expect(frames.at(-1)).toMatchObject({ + type: 'status', + job: { status: 'completed', detail: 'exit code: 0', output: { total: 10 } }, + }) + }) + + it('resumes from an explicit offset and flags an evicted resume lossy', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'spam' }) + job.append('a'.repeat(60)) + job.append('b'.repeat(60)) + await job.settle({ status: 'completed' }) + + const fresh = observed(ctx, { jobId: job.id, from: 0 }) + await fresh.done + const output = fresh.frames.find(frame => frame.type === 'output') + expect(output?.lossy).toBe(true) + expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60)) + + const resumed = observed(ctx, { jobId: job.id, from: 120 }) + await resumed.done + expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false) + expect(resumed.frames.at(-1)?.type).toBe('status') + }) + + it('splits oversized reads along the frame budget', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'wide' }) + job.append('x'.repeat(30)) + job.append('y'.repeat(30)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobOutput(ctx.jobs, { jobId: job.id }, { flushMs: 5, maxFrameBytes: 30 }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs).toHaveLength(2) + expect(outputs[0]?.next).toBe(30) + expect(outputs[1]?.next).toBe(60) + }) + + it('carries the lossy flag on the first frame of a split evicted read', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'evicted split' }) + job.append('a'.repeat(40)) + job.append('b'.repeat(40)) + job.append('c'.repeat(40)) + await job.settle({ status: 'completed' }) + const abort = new AbortController() + const frames = await collect( + observeJobOutput(ctx.jobs, { jobId: job.id, from: 0 }, { flushMs: 5, maxFrameBytes: 40 }, abort.signal), + 5, + abort, + ) + const outputs = frames.filter(frame => frame.type === 'output') + expect(outputs[0]?.lossy).toBe(true) + expect(outputs[1]?.lossy).toBeUndefined() + }) + + it('reads owned output through the request session and rejects foreign, bad, and unknown input', async () => { + const ctx = await harness() + const owner = await registerAgent(ctx, 'alice') + const job = startJob(ctx, { label: 'secret', owner: owner.id }) + job.append('classified') + await job.settle({ status: 'completed' }) + + const { frames, done } = observed(ctx, { jobId: job.id, sessionId: owner.id }) + await done + expect(frames.some(frame => frame.type === 'output' + && frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true) + + const foreign = new AbortController() + await expect(collect( + observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, foreign.signal), + 1, + foreign, + )).rejects.toThrow(/belongs to another session/) + + const abort = new AbortController() + await expect(collect( + observeJobOutput(ctx.jobs, { jobId: job.id, sessionId: owner.id, from: -1 }, OBSERVE, abort.signal), + 1, + abort, + )).rejects.toThrow(/invalid observe offset/) + + const abortUnknown = new AbortController() + await expect(collect( + observeJobOutput(ctx.jobs, { jobId: JobId('bash-999') }, OBSERVE, abortUnknown.signal), + 1, + abortUnknown, + )).rejects.toThrow(/unknown job/) + }) + + it('anchors with the live progress and takes the fast wait path for a wake that lands mid-yield', async () => { + const ctx = await harness() + const job = startJob(ctx) + job.progress('3/10') + const abort = new AbortController() + const iterator = observeJobOutput(ctx.jobs, { jobId: job.id }, OBSERVE, abort.signal)[Symbol.asyncIterator]() + + const anchor = await iterator.next() + expect(anchor.value).toMatchObject({ type: 'opened', job: { progress: '3/10' } }) + // Both chunks land while the generator is suspended on a yield, so each + // wake precedes the loop's wait(): draining the first chunk must pass + // through the already-woken fast path to read the second. + job.append('woken') + const first = await iterator.next() + expect(first.value).toMatchObject({ type: 'output' }) + job.append('again') + const second = await iterator.next() + expect(second.value).toMatchObject({ type: 'output' }) + const pending = iterator.next() + await job.settle({ status: 'completed' }) + const status = await pending + expect(status.value).toMatchObject({ type: 'status', job: { status: 'completed' } }) + expect((await iterator.next()).done).toBe(true) + }) + + it('ignores output signals for other jobs while waiting', async () => { + const ctx = await harness() + const watched = startJob(ctx, { label: 'watched' }) + const noisy = startJob(ctx, { label: 'noisy' }) + const { abort, frames, done } = observed(ctx, { jobId: watched.id }) + await wait(15) + noisy.append('unrelated') + await wait(15) + await watched.settle({ status: 'completed' }) + await done + abort.abort() + expect(frames.filter(frame => frame.type === 'output')).toEqual([]) + await noisy.settle({ status: 'completed' }) + }) + + it("closes with the removed job's terminal projection when the owner's teardown drops it mid-observation", async () => { + const ctx = await harness() + const owner = await registerAgent(ctx, 'observer-owner') + const job = startJob(ctx, { label: 'torn down', owner: owner.id }) + job.append('partial') + const { frames, done } = observed(ctx, { jobId: job.id, sessionId: owner.id }) + await wait(20) + // Teardown cancels, settles, and removes the record within one microtask + // chain; the generation wakes on the removal and closes without a read. + await owner.disposeScope() + await done + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + expect(frames.at(-1)).toMatchObject({ type: 'status', job: { id: job.id, status: 'killed' } }) + expect(() => ctx.jobs.get(job.id, owner.id)).toThrow(/unknown job/) + }) + + it('stops cleanly on abort while waiting for output', async () => { + const ctx = await harness() + const job = startJob(ctx, { label: 'idle' }) + const { abort, frames, done } = observed(ctx, { jobId: job.id }) + await wait(20) + abort.abort() + await done + expect(frames[0]?.type).toBe('opened') + expect(frames.at(-1)?.type).not.toBe('status') + await job.settle({ status: 'completed' }) + }) +}) + +describe('JobController', () => { + async function controllerHarness() { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(LocalJobRegistry) + await ctx.plugin(TypertRegistry) + ctx.jobs.attachController('job-controller-test') + await ctx.plugin(JobController, {}) + return ctx + } + + async function liveOwner(ctx: Context, rawId: string): Promise { + const session = ctx.sessions.create(SessionId(rawId)) + const owner: Agent = { + id: session.id, + options: {}, + session, + inbox: unsupportedInbox(), + status: 'idle', + ctx, + send: () => {}, + followup: () => {}, + steer: () => {}, + inject: () => {}, + cancel: () => {}, + runMaintenance: task => task(new AbortController().signal), + whenIdle: () => Promise.resolve(), + } + await ctx.agents.register(owner) + return owner + } + + it('waits for the job registry instead of serving without one', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(TypertRegistry) + ctx.plugin(JobController, {}) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.get('jobController')).toBeUndefined() + }) + + it('observe reads an owned job through the request session', async () => { + const ctx = await controllerHarness() + const owner = await liveOwner(ctx, 'observing-session') + const job = startJob(ctx, { label: 'owned run', owner: owner.id }) + job.append('hi') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + ctx.jobController.follow({ sessionId: owner.id, jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) + + it('observes an unowned job without a request session', async () => { + const ctx = await controllerHarness() + const job = startJob(ctx, { label: 'unowned run' }) + job.append('open access') + await job.settle({ status: 'completed' }) + + const abort = new AbortController() + const frames = await collect( + ctx.jobController.follow({ jobId: job.id }, abort.signal), + 3, + abort, + ) + expect(frames.map(frame => frame.type)).toEqual(['opened', 'output', 'status']) + }) + + it('rows mirrors the session-visible set and follows a settlement', async () => { + const ctx = await controllerHarness() + const owner = await liveOwner(ctx, 'listing-session') + const owned = startJob(ctx, { label: 'owned', owner: owner.id }) + startJob(ctx, { label: 'shared' }) + + const abort = new AbortController() + const iterator = ctx.jobController.list({ sessionId: owner.id }, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['owned', 'shared']) + const pending = next(iterator) + await owned.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.find(job => job.id === owned.id)).toMatchObject({ status: 'completed', detail: 'exit code: 0' }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) +}) diff --git a/packages/api/job-controller/tests/rows.host.spec.ts b/packages/api/job-controller/tests/rows.host.spec.ts new file mode 100644 index 0000000000..48bb30aedc --- /dev/null +++ b/packages/api/job-controller/tests/rows.host.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' +import { streamJobRows } from '../src/rows.ts' +import { harness, next, registerAgent, startJob, wait } from './host-harness.ts' + +const ROWS = { flushMs: 5 } + +describe('streamJobRows', () => { + it('opens with the visible set and re-sends it after each coalesced lifecycle burst', async () => { + const ctx = await harness() + const alice = await registerAgent(ctx, 'alice') + const bob = await registerAgent(ctx, 'bob') + startJob(ctx, { label: 'shared' }) + const mine = startJob(ctx, { label: 'mine', owner: alice.id }) + startJob(ctx, { label: 'theirs', owner: bob.id }) + + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + const first = await next(iterator) + expect(first?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + + // Another owner's lifecycle and this job's own output never wake the roster; + // the next frame is the one the settlement earns, and it carries the final + // byte count without a frame per append. + startJob(ctx, { label: 'theirs too', owner: bob.id }) + mine.append('progress bytes') + await wait(20) + const pending = next(iterator) + await mine.settle({ status: 'completed', detail: 'exit code: 0' }) + const second = await pending + expect(second?.jobs.map(job => job.label)).toEqual(['shared', 'mine']) + expect(second?.jobs[1]).toMatchObject({ status: 'completed', detail: 'exit code: 0', output: { total: 14 } }) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('follows progress, stopping, and registration commits', async () => { + const ctx = await harness() + const alice = await registerAgent(ctx, 'alice') + const job = startJob(ctx, { label: 'first', owner: alice.id, slowStop: true }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + await next(iterator) + + job.progress('3/10') + expect((await next(iterator))?.jobs[0]).toMatchObject({ progress: '3/10' }) + ctx.jobs.kill(job.id, alice.id, 'enough') + expect((await next(iterator))?.jobs[0]).toMatchObject({ status: 'stopping', progress: '3/10' }) + startJob(ctx, { label: 'second', owner: alice.id }) + expect((await next(iterator))?.jobs.map(entry => entry.label)).toEqual(['first', 'second']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + await job.settle({ status: 'killed' }) + }) + + it('drops an owner\'s rows when its lifecycle ends, keeping the unowned set', async () => { + const ctx = await harness() + const alice = await registerAgent(ctx, 'alice') + startJob(ctx, { label: 'shared' }) + startJob(ctx, { label: 'mine', owner: alice.id }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: alice.id }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs).toHaveLength(2) + + await alice.disposeScope() + // Teardown cancels, settles, and removes the owned job; the frames land in + // that order, and the last one holds the unowned job alone. + let frame = await next(iterator) + while (frame !== undefined && frame.jobs.length !== 1) frame = await next(iterator) + expect(frame?.jobs.map(job => job.label)).toEqual(['shared']) + abort.abort() + expect(await next(iterator)).toBeUndefined() + }) + + it('stops cleanly on abort while idle and refuses an already-aborted signal', async () => { + const ctx = await harness() + startJob(ctx, { label: 'idle' }) + const abort = new AbortController() + const iterator = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, abort.signal)[Symbol.asyncIterator]() + expect((await next(iterator))?.jobs.map(job => job.label)).toEqual(['idle']) + await wait(10) + abort.abort() + expect(await next(iterator)).toBeUndefined() + + const aborted = new AbortController() + aborted.abort() + const refused = streamJobRows(ctx.jobs, { sessionId: 'nobody' as never }, ROWS, aborted.signal)[Symbol.asyncIterator]() + await expect(refused.next()).rejects.toThrow() + }) +}) diff --git a/packages/api/job-controller/tsconfig.client.json b/packages/api/job-controller/tsconfig.client.json new file mode 100644 index 0000000000..34a2202060 --- /dev/null +++ b/packages/api/job-controller/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json new file mode 100644 index 0000000000..4e5c8c3f01 --- /dev/null +++ b/packages/api/job-controller/tsconfig.host.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/observe.ts", + "src/rows.ts", + "src/types.ts", + "src/wake.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/job-controller/tsconfig.json b/packages/api/job-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/job-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts new file mode 100644 index 0000000000..0391bc61bf --- /dev/null +++ b/packages/api/job-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-job-controller', + ['lib/types/index.js'], + { hostPhase: true }, +) diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a20b05e13..005d3fbc19 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -63,8 +63,12 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", @@ -73,10 +77,12 @@ "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 5ba2f791fc..d1220b9b93 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -17,6 +17,7 @@ import fileUploadsRemote from '@deepseek-ai/dsh-client-file-upload/remote' import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote' import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' import terminalRemote from '@deepseek-ai/dsh-api-terminal-controller/remote' import workspaceFilesRemote from '@deepseek-ai/dsh-api-workspace-files/remote' @@ -46,13 +47,14 @@ export type {} from '@deepseek-ai/dsh-subagent/remote' export type * from '@deepseek-ai/dsh-subagent/client' export type {} from '@deepseek-ai/dsh-api-session-controller/remote' export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-job-controller/remote' +export type * from '@deepseek-ai/dsh-api-job-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' export type * from '@deepseek-ai/dsh-api-workspace-controller/types' export type {} from '@deepseek-ai/dsh-api-workspace-files/remote' export type * from '@deepseek-ai/dsh-api-workspace-files/types' export type {} from '@deepseek-ai/dsh-api-terminal-controller/remote' export type * from '@deepseek-ai/dsh-api-terminal-controller/types' -export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -168,7 +170,8 @@ export async function apply(ctx: Context): Promise<() => Promise> { for (const contribution of [ agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote, pluginInventoryRemote, pluginManagerRemote, messageFeedbackRemote, sessionFeedbackRemote, fileUploadsRemote, sessionReferencesRemote, - permissionPresetsRemote, subagentsRemote, sessionRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, officeToPdfRemote, + permissionPresetsRemote, subagentsRemote, sessionRemote, jobRemote, workspaceRemote, workspaceFilesRemote, terminalRemote, + officeToPdfRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index 301798c890..d7464526d3 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -68,6 +68,9 @@ { "path": "../../interaction/user-questions" }, + { + "path": "../job-controller/tsconfig.client.json" + }, { "path": "../session-controller/tsconfig.client.json" }, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index e63c0b2a46..0e8bb05b38 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -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/session-controller/README.md -README.md: 49f38e5af8f0939dd8f1ccafcd7928b10954ca33 -README.zh.md: 57112fc5ce88567ecfe415ed7ee4dd5ac9d34224 +README.md: 6d8479ea7a88a1a68df559b04cfd13c9f44421cd +README.zh.md: 2591bbf86fba57deabc0a4282c868d84b24ec105 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 49f38e5af8..6d8479ea7a 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -37,11 +37,13 @@ Explicit-id `session.create` adopts a live Session or resumes a persisted Sessio Client list refreshes retain unchanged row objects and reuse the items array when order and values match. Each row's `retainedBy` contains positive local reference-source counts; Host metadata refreshes cannot overwrite them. Cache membership checks use a per-refresh ID set, so reconciliation grows linearly with the current list and retained cache sizes. Host summary updates replace running and Agent availability; local create/fork responses only fill missing metadata on existing rows. Removed ordinary Sessions retain projection stores only when their catalogs contain children. +Background-job rows and observation streams belong to [`dsh-api-job-controller`](../job-controller/README.md); the control stream carries projections only. + Projection reads retain loading and failure state independently of their values. Reconnect cancels reads from the previous connection and reloads previously requested projections; live membership updates arrive through the control stream. Parent availability comes from Host summaries and remains unknown until a summary or a successful list baseline establishes it. Address lookup resolves projected children without selecting them or creating scopes. Accepted prompts and observed running keep their Client display conversion across list refresh, reconnect, and Session-object replacement. The Manager retains these observations by Session id; they do not establish durable history. Drafts, projection stores, and sidebar display rules retain their existing behavior. The [blank rollback decision](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.md) defines retention and late-response handling. -The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces jobs and projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, `append`, and `settle-assistant` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. The Web adapter explicitly opts into cursorless Assistant frames: each opening carries the active attempt's `startedAfterSeq`, `nextIndex`, and compact stream, and every stream member becomes a Client-only `assistant/live-chunk` entry ordered between durable cursors. The Host captures a follower-local arrival ordinal with that baseline and suppresses buffered frames at or before the cut; a replacement Agent may restart frame revision at one. A durable `assistant/message` or `assistant/attempt` arriving after an active opening stays staged only when its seq follows `startedAfterSeq` and its Turn and Step match; the matching end type, seq, and index publishes one named settlement delta that retires the attempt's transient rows and adds the durable entry while earlier same-step retries remain visible. Revision, dense-index, or settlement gaps for a known attempt reopen follow, while a controller that missed the start ignores unknown-attempt frames and publishes their durable settlement normally. An abandoned end publishes a settlement delta without a durable entry so its transient rows retire immediately. A durable gap-repair page has no Assistant baseline, so its held notification reopens follow once for a paired page and baseline. Every history record covers exactly its event seq. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces projection state instead of treating transient values as durable events. At each ready Host generation, a synchronous Client subscription clears retained projection values and watermarks before refreshing queries and restarting the control stream, including Sessions absent from the control baseline. The first control stream waits for generation readiness, so its opening values cannot precede invalidation. Outstanding list responses from the previous generation cannot republish those values. Within a generation, a delayed control baseline cannot overwrite or clear newer sequenced values, whether they arrived through a live Session's list block, a history page, or a frame; a cached list block, viewed from the projection cache for a cold Session, yields to the connected Session's baseline whatever watermark it carries. The durable `inbox` projection carries both pending lists through the same cold-read and reconnect path as other projections. Client Agent contexts provide the identity used by the independent [`fileUpload`](../../client/file-upload/README.md) service; Session objects expose lifecycle, prompt, queue, and history operations rather than file transfer. The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, including pending messages in the `inbox` projection. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. @@ -70,6 +72,8 @@ References keep local Session data, scoped Contexts, and history streams alive, `SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. +`workspacePathApplications({ path })` returns the serving desktop’s file handlers after the same filesystem mapping check used by `openWorkspacePath`. An optional `application` in the open request selects a currently registered handler without changing the system default. Application names, default selection, icons, and platform coverage come from [native-command](../../util/native-command/README.md). These operations do not activate an Agent or append Session events. Native failures return bounded messages; original command errors remain Host-side causes. + ----- @@ -97,7 +101,6 @@ No direct effect; model requests remain owned by the Agent and LLM packages. - The image byte cap does not validate decoded dimensions or pixel count. -- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. - File-reference completion uses the shared Agent lookup and can resume a cold Session; the `skills/list` catalog is the non-activating alternative for skill metadata. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 57112fc5ce..2591bbf86f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -37,11 +37,13 @@ Client 列表行和驻留 Session 使用当前 `sessionListMetadata` 投影纠 Client 列表刷新保留未变化的行对象,并在顺序和值均相同时复用条目数组。每行的 `retainedBy` 包含正数的本地引用来源计数,Host 元数据刷新不能覆盖它们。缓存成员检查使用每次刷新构建的 ID 集合,因此对账成本随当前列表和保留缓存的规模线性增长。 Host 摘要更新会替换运行状态与 Agent 可用性;本地 create/fork 响应只补充已有行缺失的元数据。普通 Session 被移除后,仅在目录仍有子项时保留其投影 store。 +后台 job 的名册行与观测流属于 [`dsh-api-job-controller`](../job-controller/README.zh.md);控制流只承载投影。 + 投影读取独立于其值保留加载与失败状态。重连会取消上一连接的读取,并重新加载已请求的投影;实时成员更新通过 control stream 到达。父 Agent 可用性来自 Host 摘要,在收到对应摘要或成功的列表 baseline 前保持未知。地址查找直接解析投影中的子项,不会选择它们或创建 scope。 已受理的提示词与已观察到的运行,会使客户端展示转换跨列表刷新、重连及 Session 对象替换保留。Manager 按 Session id 保留这些观察;它们不证明持久历史已经产生。草稿、投影存储和侧边栏展示规则保留原有行为。[blank 回退修复决策](../../../.agents/notes/implemented/bug-fix/2026-09-15-client-session-blank-reconciliation.zh.md) 定义保留期限及迟到响应处理。 -Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 jobs 和 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 +Client 适配器提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend`、`append` 与 `settle-assistant` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条消息,而 `loadThrough(seq)`——轮次跳转加载器——按每页 200 条消息循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。Web 适配器显式选择接收无 cursor 的 Assistant frame:每个 opening 携带活跃 attempt 的 `startedAfterSeq`、`nextIndex` 与紧凑 stream,每个 stream member 都成为排在持久 cursor 之间的 Client-only `assistant/live-chunk` 条目。Host 会随该 baseline 捕获 follower 本地到达序号,并抑制该 cut 及之前的 buffered frame;replacement Agent 可以从 revision 一重新开始。活跃 opening 之后到达的持久 `assistant/message` 或 `assistant/attempt` 只有在其 seq 晚于 `startedAfterSeq` 且轮次与步骤匹配时才会保持暂存;匹配的 end type、seq 与 index 会发布一个具名 settlement delta,删除该 attempt 的瞬态 row、加入持久条目,并保留同一步骤中更早的 retry。已知 attempt 的 revision、密集 index 或 settlement 缺口会重新打开 follow;若 controller 错过 start,则忽略 unknown-attempt frame,并正常发布其持久 settlement。Abandoned end 会发布不含持久条目的 settlement delta,使瞬态 row 立即退出。持久缺口修复 page 不携带 Assistant baseline,因此 held notification 会重新打开 follow 一次,以取得配对的 page 与 baseline。每条历史 record 只覆盖自身的事件 seq。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 projection 状态,而不会把瞬态值当作 durable event。每次 Host generation 就绪时,同步的 Client 订阅会先清除保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括 control baseline 中没有列出的 Session。首次 control stream 会等待 generation 就绪,确保其 opening 值不会先于旧状态清理到达。上一代尚未完成的 list 响应无法重新发布这些值。同一 generation 内,延迟到达的 control baseline 不能覆盖或清除较新的 sequenced 值,无论它们来自活 Session 的 list block、history page 还是 frame;冷 Session 从 projection cache 看出来的 cached list block 则不论水位都让位于建连 Session 的 baseline。持久 `inbox` 投影通过与其他投影相同的冷读取和重连路径传输两份待处理列表。Client Agent 上下文提供独立 [`fileUpload`](../../client/file-upload/README.zh.md) 服务使用的身份;Session 对象提供生命周期、prompt、queue 与历史操作,不提供文件传输。 Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与提示词之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。提示词的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,`inbox` 投影中的待处理消息也保留同一 source。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的提示词失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从持久事件重建会话。 @@ -70,6 +72,8 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与沙箱 CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 +`workspacePathApplications({ path })` 使用与 `openWorkspacePath` 相同的文件系统映射校验,返回服务端桌面上该文件的关联应用。打开请求中的可选 `application` 指定当前关联的应用,不修改系统默认应用。应用名称、默认项、图标和平台支持范围由 [native-command](../../util/native-command/README.zh.md) 提供。这些操作不激活 Agent,也不追加会话事件。原生操作失败时返回简短消息,原始命令异常作为 Host 端原因保留。 + ----- @@ -97,7 +101,6 @@ Fork 复制 `atSeq` 所选的精确事件前缀,包含切点事件,允许在 - 图片字节上限不校验解码后的尺寸或像素数。 -- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第零字节重新传输整个文件。 - 文件引用补全使用共享 Agent lookup,因此可能恢复冷 Session;`skills/list` 目录是不激活 Agent 的 skill 元数据读取路径。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 643c553e0a..a391fefa37 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -87,7 +87,6 @@ "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", @@ -107,9 +106,6 @@ "@deepseek-ai/dsh-workspace": "workspace:^" }, "peerDependenciesMeta": { - "@deepseek-ai/dsh-jobs": { - "optional": true - }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -134,7 +130,6 @@ "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", "@deepseek-ai/dsh-permission-presets": "workspace:^", diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts index 9744bf3c75..50af09df13 100644 --- a/packages/api/session-controller/src/client/sessions/manager.ts +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -10,7 +10,6 @@ import type { SessionProjectionHints, SessionRenameValue, SessionSummary, - SessionJob as JobView, } from '../../types.ts' import { mergeOrderedBaseline } from '../ordered-baseline.ts' import { isRemoteFailure } from '@deepseek-ai/dsh-api-gateway/client' @@ -56,8 +55,7 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RemoteFailure | null projectionsBySession: Readonly> - /** Background jobs per session; an absent key is an empty set. */ - jobsBySession: Readonly> + } /** Shared projection values and the lifecycle of their explicit baseline read. */ @@ -112,12 +110,7 @@ export class SessionManager { private readonly addresses = new Map() private readonly projectionLoads = new Map() private readonly projectionInflight = new Map() - /** - * Background jobs per session, last-wins from Session Controller's control - * stream. An empty set is stored as an absent key, so absence and `[]` are - * one representation. - */ - private readonly jobsBySession = new Map() + private listSnapshotCache: SessionListSnapshot /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry @@ -628,21 +621,11 @@ export class SessionManager { this.replaceControlBaseline(frame.value) return } - if (frame.type === 'projection') { - this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq)) - this.notifier.markDirty() - return - } - if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) - else this.jobsBySession.set(frame.sessionId, frame.jobs) + this.projectionStore(frame.sessionId).apply(frame.key, frame.value, SessionSeq(frame.seq)) this.notifier.markDirty() } private replaceControlBaseline(baseline: SessionControlBaseline): void { - this.jobsBySession.clear() - for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { - if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) - } for (const [sessionId, block] of Object.entries(baseline.projections)) { const store = this.projectionStore(sessionId as SessionId) @@ -699,7 +682,6 @@ export class SessionManager { : { kind: 'remove', sessionId }) if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) else this.sessions.get(sessionId)?.handleRemoved() - this.jobsBySession.delete(sessionId) const catalog = this.projectionStores.get(sessionId)?.values().subagentCatalog if (!durableSubagent && (catalog === undefined || catalog.length === 0)) { this.projectionStores.delete(sessionId) @@ -812,7 +794,6 @@ export class SessionManager { sessionId, { values: store.values(), state: 'idle', error: null, ...this.projectionLoads.get(sessionId) }, ])), - jobsBySession: Object.fromEntries(this.jobsBySession), } } } diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index 2d5cea16f0..48d8f6a533 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -5,7 +5,6 @@ import { SessionSeq, type SessionId } from '@deepseek-ai/dsh-session/types' import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' -import type { SessionJob as JobView } from '../../types.ts' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' import { createSnapshotStore, notifySubscribers, type ObservableSnapshot, type SnapshotStore, @@ -61,12 +60,6 @@ export interface SessionListState { phase: SessionListPhase /** Shared projection values and explicit-read state, including unopened Sessions. */ projectionsBySession: Readonly> - /** - * Background jobs each session can see, mirrored last-wins from Session - * Controller's control baseline and `jobs` frames. A missing key is an empty - * set, so consumers read absence rather than a sentinel. - */ - jobsBySession: Readonly> } /** Structured session-create failure. */ @@ -263,7 +256,7 @@ export class ClientSessions implements ISessions { ) { this.manager = new SessionManager(remote) this.list = createSnapshotStore({ - ids: [], byId: {}, phase: 'pending', projectionsBySession: {}, jobsBySession: {}, + ids: [], byId: {}, phase: 'pending', projectionsBySession: {}, }) const disposeManagerProjection = this.manager.subscribe(() => { this.projectList() }) rootCtx.effect(() => async () => { @@ -614,7 +607,7 @@ export class ClientSessions implements ISessions { private projectList(): void { const previousById = this.list.getSnapshot().byId const { - items, phase, projectionsBySession, jobsBySession, + items, phase, projectionsBySession, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -681,7 +674,7 @@ export class ClientSessions implements ISessions { ...(title === undefined ? {} : { title, displayTitle: title }), } } - this.list.set({ ids, byId, phase, projectionsBySession, jobsBySession }) + this.list.set({ ids, byId, phase, projectionsBySession }) } private startScopeDrop( diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts index 3fb8d0032e..8d27396d29 100644 --- a/packages/api/session-controller/src/control.ts +++ b/packages/api/session-controller/src/control.ts @@ -1,9 +1,7 @@ -/** Live Session jobs and projection state with reconnect baselines. */ +/** Live Session projection state with reconnect baselines. */ import type { Context } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' import { Deque } from '@deepseek-ai/dsh-deque' -import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' import type { Session, SessionId, } from '@deepseek-ai/dsh-session' @@ -11,7 +9,6 @@ import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { SessionControlBaseline, SessionControlFrame, - SessionJob, SessionProjectionBaseline, SessionProjectionValues, } from './types.ts' @@ -20,7 +17,7 @@ import type { export class SessionControlController { private readonly streams = new Set() - /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ + /** @param ctx - Host context carrying live Agent and projection services. */ constructor(private readonly ctx: Context) { ctx.sessionProjections.onChanged((session, key, value, seq) => { this.broadcast({ @@ -31,13 +28,6 @@ export class SessionControlController { seq, }) }) - ctx.inject(['jobs'], (jobsCtx) => { - jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) - }) - ctx.on('session/created', (session) => { - const jobs = this.jobsFor(this.ctx.agents.get(session.id)) - if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) - }) ctx.effect(() => () => { for (const stream of this.streams) stream.end() this.streams.clear() @@ -64,13 +54,7 @@ export class SessionControlController { private baseline(): SessionControlBaseline { const sessions = this.ctx.sessions.list() - const jobs = Object.create(null) as Record - for (const session of sessions) { - const agent = this.ctx.agents.get(session.id) - jobs[session.id] = this.jobsFor(agent) - } return { - jobs, projections: this.projectionBaseline(sessions), } } @@ -90,25 +74,6 @@ export class SessionControlController { return blocks } - private onJobsChanged(owner: Agent | undefined): void { - if (owner !== undefined) { - this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) - return - } - for (const session of this.ctx.sessions.list()) { - this.broadcast({ - type: 'jobs', - sessionId: session.id, - jobs: this.jobsFor(this.ctx.agents.get(session.id)), - }) - } - } - - private jobsFor(agent: Agent | undefined): SessionJob[] { - const jobs = this.ctx.get('jobs') - return jobs === undefined ? [] : jobs.list(agent).map(jobView) - } - private broadcast(frame: SessionControlFrame): void { for (const stream of this.streams) stream.push(frame) } @@ -154,15 +119,3 @@ class ControlQueue { } } } - -function jobView(job: JobSnapshot): SessionJob { - return { - id: job.id, - kind: job.kind, - label: job.label, - status: job.status, - ...(job.detail === undefined ? {} : { detail: job.detail }), - startedAt: job.startedAt, - ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), - } -} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b6f5f0dfa0..d830d2b661 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -8,7 +8,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-client-file-upload' -import { canOpenNativePath, nativeFileManager, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, nativeFileManager, nativeFileApplications, openNativeFileApplication, openNativeAssociatedPath, revealNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionId } from '@deepseek-ai/dsh-session' import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' @@ -29,6 +29,7 @@ import { SessionSkillCatalog } from './skill-catalog.ts' import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, + SessionWorkspacePathApplication, SessionAttachmentRequest, SessionAttachmentValue, SessionCancelRequest, @@ -83,6 +84,10 @@ export interface Config { export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native file-association query. */ + readonly fileApplications?: typeof nativeFileApplications + /** Explicit registered-application handoff. */ + readonly openFileApplication?: typeof openNativeFileApplication /** Native file-manager handoff. */ readonly revealPath?: (path: string, signal: AbortSignal) => Promise /** Native handoff availability probe. */ @@ -115,6 +120,8 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly fileApplications: typeof nativeFileApplications + private readonly openFileApplication: typeof openNativeFileApplication private readonly revealPath: (path: string, signal: AbortSignal) => Promise private readonly canOpenPath: () => boolean private readonly promotions = new Set>() @@ -142,6 +149,8 @@ export class SessionController extends TypertRemoteService { }, 'session-controller.promotions') this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) this.listState = new ApiSessionList(ctx) + this.fileApplications = internals.fileApplications ?? nativeFileApplications + this.openFileApplication = internals.openFileApplication ?? openNativeFileApplication this.openPath = internals.openPath ?? openNativeAssociatedPath this.revealPath = internals.revealPath ?? revealNativePath this.canOpenPath = internals.canOpenPath @@ -309,35 +318,59 @@ export class SessionController extends TypertRemoteService { request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise { - if (request.path.length === 0) { - throw new RemoteError( - 'gateway/bad-request', - 'session.openWorkspacePath requires a non-empty path', - {}, - ) - } - signal.throwIfAborted() try { - const hostPath = resolve(request.path) - const { fs } = this.ctx - const mapped = fs.processPathFromHostPath(hostPath) - if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { - throw new Error('Path has no verified Host path') - } - signal.throwIfAborted() - if (request.action === 'reveal') await this.revealPath(request.path, signal) - else await this.openPath(request.path, signal) + const path = await this.verifyDesktopPath(request.path, signal) + if (request.action === 'reveal') await this.revealPath(path, signal) + else if (request.application !== undefined) await this.openFileApplication(path, request.application, signal) + else await this.openPath(path, signal) return { opened: true } } catch (error: unknown) { if (signal.aborted) throw new RemoteError('gateway/cancelled', 'path open was aborted', {}) + if (error instanceof RemoteError) throw error throw new RemoteError( 'gateway/internal', - `path open failed: ${error instanceof Error ? error.message : String(error)}`, + 'path open failed', {}, + { cause: error }, ) } } + /** + * Query current file handlers on the serving desktop without activating an Agent. + * @param request - file path in Host filesystem syntax. + * @param signal - caller lifetime, propagated to filesystem and desktop queries. + * @returns OS application names, icons, and default selection; empty when desktop opening is unavailable. + * @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails. + */ + @Remote('workspacePathApplications') + async workspacePathApplications( + request: { readonly path: string }, signal: AbortSignal, + ): Promise { + if (!this.canOpenPath()) return [] + try { + const path = await this.verifyDesktopPath(request.path, signal) + return await this.fileApplications(path, signal) + } catch (error: unknown) { + if (signal.aborted) throw new RemoteError('gateway/cancelled', 'application query was aborted', {}) + if (error instanceof RemoteError) throw error + throw new RemoteError('gateway/internal', 'file application query failed', {}, { cause: error }) + } + } + + private async verifyDesktopPath(path: string, signal: AbortSignal): Promise { + if (path.length === 0) throw new RemoteError('gateway/bad-request', 'A non-empty file path is required', {}) + signal.throwIfAborted() + const hostPath = resolve(path) + const { fs } = this.ctx + const mapped = fs.processPathFromHostPath(hostPath) + if (mapped === undefined || fs.processPath(await fs.resolve(mapped, { signal })) !== hostPath) { + throw new RemoteError('gateway/bad-request', 'Path has no verified Host path', {}) + } + signal.throwIfAborted() + return hostPath + } + /** * Rename one Session after explicitly resuming it. * @param request - Session identity and proposed title. @@ -465,6 +498,7 @@ export class SessionController extends TypertRemoteService { return this.controlState.control(signal) } + } export { buildModelCatalog } diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 728df9e55b..9e3da329ee 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -1,4 +1,5 @@ /** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ +import type { NativeFileApplication } from '@deepseek-ai/dsh-native-command/types' import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, @@ -8,7 +9,6 @@ import type { LlmAttemptId, MessageId } from '@deepseek-ai/dsh-llm/brand' import type { TextBlock } from '@deepseek-ai/dsh-llm' import type { SessionId, SessionSeqCursor } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { JobId } from '@deepseek-ai/dsh-jobs/brand' import type { JsonValue } from '@deepseek-ai/dsh-util-values' import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' @@ -381,6 +381,8 @@ export interface SessionCancelValue { export interface SessionOpenWorkspacePathRequest { /** File-manager navigation when requested; omission uses the default application. */ readonly action?: 'reveal' + /** Registered application identifier; ignored for reveal. Omission preserves the operating system default. */ + readonly application?: string /** Path after best-effort Session workspace resolution, in Host filesystem syntax. */ readonly path: string } @@ -552,20 +554,8 @@ export type SessionFollowFrame = | SessionEventEntry | { readonly type: 'assistant-stream'; readonly frame: SessionAssistantStreamFrame } -/** Browser-safe background-job row. */ -export interface SessionJob { - readonly id: JobId - readonly kind: string - readonly label: string - readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' - readonly detail?: string - readonly startedAt: number - readonly finishedAt?: number -} - /** Complete live control baseline emitted once per control stream generation. */ export interface SessionControlBaseline { - readonly jobs: Readonly> readonly projections: Readonly> } @@ -580,7 +570,6 @@ export interface SessionProjectionUpdate { /** Host-wide live state stream. Each generation starts with exactly one baseline. */ export type SessionControlFrame = | { readonly type: 'baseline'; readonly value: SessionControlBaseline } - | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } | ({ readonly type: 'projection' } & SessionProjectionUpdate) declare module '@deepseek-ai/cordis' { @@ -624,3 +613,6 @@ declare module '@deepseek-ai/cordis' { /** JSON-compatible projection value accepted by list consumers. */ export type SessionProjectionValue = JsonValue + +/** Application metadata returned by the serving desktop for one file. */ +export type SessionWorkspacePathApplication = NativeFileApplication diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index 5323f64b17..11c98407eb 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -18,7 +18,7 @@ const ROSTER = webApp.closure([SELF]) const it = createClientTest({ roster: ROSTER }) const EVENTS = '$events' const CONTROL = 'session/control' -const BASELINE = { type: 'baseline', value: { jobs: {}, projections: {} } } +const BASELINE = { type: 'baseline', value: { projections: {} } } /** The first client boot pays the cold module transform of the cone. */ const COLD_BOOT_TIMEOUT_MS = 60_000 @@ -88,7 +88,7 @@ describe('Session Controller Client apply', () => { }] })) let projection = { asOfSeq: 20, values: { title: 'Before restart' } } mock.stream(CONTROL, (_args, stream) => { - stream.push({ type: 'baseline', value: { jobs: {}, projections: { [sessionId]: projection } } }) + stream.push({ type: 'baseline', value: { projections: { [sessionId]: projection } } }) }) const { client, sessions } = await bench(start) await vi.waitFor(() => { diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts deleted file mode 100644 index 46b3dff031..0000000000 --- a/packages/api/session-controller/tests/control-jobs.host.spec.ts +++ /dev/null @@ -1,237 +0,0 @@ -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { JobOutcome } from '@deepseek-ai/dsh-jobs' -import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import type { Session } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import { describe, expect, it } from 'vitest' -import { SessionControlController } from '../src/control.ts' -import type { SessionControlFrame } from '../src/types.ts' -import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' - -type BaselineFrame = Extract -type JobFrame = Extract - -function producer(label = 'sleep 60') { - let settle!: (outcome: JobOutcome) => void - const reads = { count: 0 } - const spec = { - kind: 'bash' as const, - label, - run: () => ({ - cancel: () => {}, - done: new Promise((resolve) => { settle = resolve }), - readOutput: () => { reads.count += 1; return 'stolen output' }, - }), - } - return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } -} - -async function harness(withJobs: boolean): Promise<{ - ctx: Context - session: Session - agent: Agent - control: SessionControlController -}> { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) - if (withJobs) { - await ctx.plugin(LocalJobRegistry) - ctx.jobs.attachController('session-controller-test') - } - const session = ctx.sessions.create() - const agent: Agent = { - id: session.id, - options: {}, - session, - inbox: unsupportedInbox(), - status: 'idle', - ctx, - send: () => {}, - followup: () => {}, - steer: () => {}, - inject: () => {}, - cancel: () => {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } - await ctx.agents.register(agent) - const control = new SessionControlController(ctx) - await new Promise(resolve => setTimeout(resolve, 0)) - return { ctx, session, agent, control } -} - -async function baseline(control: SessionControlController): Promise { - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - const first = await iterator.next() - abort.abort() - await iterator.next() - if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline') - return first.value -} - -async function collectJobs( - iterable: AsyncIterable, - count: number, - abort: AbortController, -): Promise { - const jobs: JobFrame[] = [] - for await (const frame of iterable) { - if (frame.type !== 'jobs') continue - jobs.push(frame) - if (jobs.length >= count) abort.abort() - } - return jobs -} - -describe('Session control jobs baseline', () => { - it('represents an attached session with no jobs as an empty set', async () => { - const { session, control } = await harness(true) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('carries the visible set when the stream opens', async () => { - const { ctx, session, agent, control } = await harness(true) - ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) - const frame = await baseline(control) - const jobs = frame.value.jobs[session.id] - expect(jobs).toHaveLength(1) - const [job] = jobs ?? [] - expect(job?.startedAt).toBeTypeOf('number') - expect({ ...job, startedAt: 0 }).toEqual({ - id: 'bash-1', - kind: 'bash', - label: 'pnpm run build', - status: 'running', - startedAt: 0, - }) - }) -}) - -describe('Session control jobs updates', () => { - it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => { - const { ctx, control } = await harness(true) - const abort = new AbortController() - const iterator = control.control(abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) - const task = producer('already running') - const id = ctx.jobs.start(task.spec) - await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } }) - - const created = ctx.sessions.create(SessionId('late-session')) - await expect(iterator.next()).resolves.toMatchObject({ - value: { - type: 'jobs', - sessionId: created.id, - jobs: [expect.objectContaining({ id, label: 'already running' })], - }, - }) - - task.settle({ status: 'completed' }) - abort.abort() - await iterator.return?.() - }) - - it('pushes the owner whole set on registration, stopping, and settlement', async () => { - const { ctx, session, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - - const frames = await collected - expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) - expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) - expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') - expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') - }) - - it('drops internal registry fields from the browser view', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) - - const [frame] = await collected - expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([ - 'id', - 'kind', - 'label', - 'startedAt', - 'status', - ]) - }) - - it('fans an unowned change out to every attached session', async () => { - const { ctx, control } = await harness(true) - const second = ctx.sessions.create() - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 2, abort) - - ctx.jobs.start(producer('open to every caller').spec) - - const frames = await collected - expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) - expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) - for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') - }) - - it('does not resume persisted sessions while projecting an unowned change', async () => { - const { ctx, control } = await harness(true) - const coldId = SessionId('session-cold-tasks') - let loaded = false - ctx.provide('sessionPersistence', { - list: async () => [{ version: SESSION_FORMAT_VERSION, id: coldId, createdAt: 5, cwd: '/tmp' }], - locate: () => undefined, - load: () => { loaded = true; throw new Error('job projection must not load a cold log') }, - } as never) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 1, abort) - - ctx.jobs.start(producer().spec) - await collected - expect(loaded).toBe(false) - expect(ctx.agents.get(coldId)).toBeUndefined() - }) - - it('reports empty sets when no jobs registry is composed', async () => { - const { session, control } = await harness(false) - const frame = await baseline(control) - expect(frame.value.jobs[session.id]).toEqual([]) - }) - - it('never consumes model output while projecting a lifecycle', async () => { - const { ctx, agent, control } = await harness(true) - const abort = new AbortController() - const collected = collectJobs(control.control(abort.signal), 3, abort) - - const task = producer() - const id = ctx.jobs.start({ ...task.spec, owner: agent }) - ctx.jobs.kill(id, agent, 'test') - task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) - await collected - - expect(task.reads.count).toBe(0) - }) - - it('never consumes model output while producing a baseline', async () => { - const { ctx, agent, control } = await harness(true) - const task = producer() - ctx.jobs.start({ ...task.spec, owner: agent }) - - const frame = await baseline(control) - - expect(frame.value.jobs[agent.id]).toHaveLength(1) - expect(task.reads.count).toBe(0) - }) - -}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts index 0c635753ea..ad24ad94fb 100644 --- a/packages/api/session-controller/tests/control-queue.host.spec.ts +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -118,6 +118,23 @@ describe('Session control Inbox projection', () => { await iterator.next() }) + it('projects inbox state for live and cold sessions', async () => { + const { ctx, control, inbox } = await harness() + inbox.append('next-turn', message('queued')) + const cold = ctx.sessions.create(SessionId('cold-session')) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + if (opened.done || opened.value.type !== 'baseline') throw new Error('missing baseline') + expect(opened.value.value.projections[cold.id]?.values.inbox).toEqual({ 'next-turn': [], 'next-step': [] }) + expect(opened.value.value.projections['queue-session' as SessionId]?.values.inbox).toMatchObject({ + 'next-turn': [expect.objectContaining({ content: [{ type: 'text', text: 'queued' }] })], + }) + abort.abort() + await iterator.next() + }) + it('projects the prompt rpcId from a user-rpc source and omits it elsewhere', async () => { const { control, inbox } = await harness() const identified = createUserMessage({ diff --git a/packages/api/session-controller/tests/inbox-projection.client.spec.ts b/packages/api/session-controller/tests/inbox-projection.client.spec.ts index 9ae17056e6..0522c8db35 100644 --- a/packages/api/session-controller/tests/inbox-projection.client.spec.ts +++ b/packages/api/session-controller/tests/inbox-projection.client.spec.ts @@ -79,7 +79,7 @@ describe('Inbox projection intake', () => { manager.handleControlFrame({ type: 'baseline', - value: { jobs: {}, projections: { [SID]: { + value: { projections: { [SID]: { asOfSeq: 20, values: key === 'included' ? { inbox: stale } : {}, } } }, }) @@ -107,7 +107,7 @@ describe('Inbox projection intake', () => { manager.handleControlFrame({ ...inboxFrame({ ...empty, 'next-turn': [ghost] }), seq: 20 }) manager.handleControlFrame({ ...inboxFrame(empty), sessionId: hiddenSessionId, seq: 20 }) const face = manager.get(SID).projections.faceOf('inbox') - const baseline = { type: 'baseline', value: { jobs: {}, projections: {} } } as const + const baseline = { type: 'baseline', value: { projections: {} } } as const const result = ok({ items: [ { sessionId: SID, updatedAt: 1, running: false, blank: false, agentAvailable: false, projections: { kind: 'cached' as const, asOfSeq: 1, values: { inbox: empty } } }, diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts index 55d182110a..4d91e0b77f 100644 --- a/packages/api/session-controller/tests/manager.client.spec.ts +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -8,7 +8,6 @@ import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' import { SessionSeq } from '@deepseek-ai/dsh-session/types' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' -import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' import { ok, type RemoteMock } from '@deepseek-ai/dsh-remote-mock' import { createClientTest, type ClientTestFixtures, webApp, @@ -526,10 +525,9 @@ describe('list lifecycle', () => { // The control baseline is the connected Session's own value: it replaces // the list-surface title even at the same cursor. manager.handleControlFrame({ - type: 'baseline', value: { jobs: { [S1]: [] }, projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } }, + type: 'baseline', value: { projections: { [S1]: { asOfSeq: -1, values: { title: 'cold baseline' } } } }, }) expect(manager.getListSnapshot().items[0]?.title).toBe('cold baseline') - expect(manager.getListSnapshot().jobsBySession).toEqual({}) } finally { await manager.dispose() } @@ -666,7 +664,6 @@ describe('list lifecycle', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, }, }) @@ -1422,64 +1419,3 @@ describe('running facts without UI reminders', () => { expect(manager.getListSnapshot()).not.toHaveProperty('current') }) }) - -describe('background-job mirror', () => { - const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ - id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, - }) - const tasksFrame = ( - sessionId: SessionId, - jobs: unknown[], - ): Extract => ({ - type: 'jobs', sessionId, jobs: jobs as never, - }) - - it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) - const first = manager.getListSnapshot().jobsBySession - expect(first[S1]).toEqual([view()]) - expect(first[S2]?.[0]?.label).toBe('other') - - // Last-wins: the newer whole set replaces, it does not merge. - manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) - expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) - }) - - it('stores an emptied set as an absent key so absence and [] read alike', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleControlFrame(tasksFrame(S1, [view()])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) - manager.handleControlFrame(tasksFrame(S1, [])) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('clears the mirror when the next control baseline has no jobs', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleControlFrame({ - type: 'baseline', - value: { jobs: {}, projections: {} }, - }) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('drops the rows when the session is removed, whichever stream lands first', ({ mock, remote }) => { - const manager = makeManager(mock, remote) - manager.handleSessionAdded(summary(S1, { blank: true })) - manager.handleControlFrame(tasksFrame(S1, [view()])) - manager.handleSessionRemoved(S1) - expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) - }) - - it('notifies list subscribers so an open header re-renders without a poll', async ({ mock, remote }) => { - const manager = makeManager(mock, remote) - const seen = vi.fn() - manager.subscribe(seen) - manager.handleControlFrame(tasksFrame(S1, [view()])) - // The notifier batches on a microtask; the frame itself is already applied. - await Promise.resolve() - expect(seen).toHaveBeenCalled() - }) -}) diff --git a/packages/api/session-controller/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts index 3ab6ab2de1..c3a14febf3 100644 --- a/packages/api/session-controller/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -241,7 +241,6 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, }, }) @@ -267,7 +266,6 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: { title: 'Connected title' } } }, }, }) @@ -299,7 +297,6 @@ describe('manager frame routing', () => { manager.handleControlFrame({ type: 'baseline', value: { - jobs: {}, projections: { [sid('s1')]: { asOfSeq: 2, values: { title: 'Delayed baseline' } } }, }, }) diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index 6a14c7761d..e8216b5214 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -1,3 +1,4 @@ +import { resolve } from 'node:path' import * as nativeCommand from '@deepseek-ai/dsh-native-command' import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -65,11 +66,11 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: '/workspace/project/src/a.ts' }, signal)) .resolves.toEqual({ ok: true, value: { opened: true } }) - expect(openPath).toHaveBeenCalledWith('/workspace/project/src/a.ts', signal) + expect(openPath).toHaveBeenCalledWith(resolve('/workspace/project/src/a.ts'), signal) expect(ctx.agents.list()).toEqual([]) }) - it('preserves relative and absolute Host-resolvable paths', async () => { + it('normalizes relative and absolute Host-resolvable paths', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const remote = createSessionTestRemote(ctx, { @@ -80,7 +81,7 @@ describe('session/openWorkspacePath', () => { await remote.openWorkspacePath({ path: '/workspace/result.html' }) await remote.openWorkspacePath({ path: 'result.html' }) - expect(openPath.mock.calls.map(call => call[0])).toEqual(['/workspace/result.html', 'result.html']) + expect(openPath.mock.calls.map(call => call[0])).toEqual([resolve('/workspace/result.html'), resolve('result.html')]) }) it('rejects empty paths before opening anything', async () => { @@ -100,7 +101,7 @@ describe('session/openWorkspacePath', () => { it('preserves native opener failure and cancellation results', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => - Promise.reject(new Error('desktop unavailable'))) + Promise.reject(new Error('Command failed: powershell.exe -EncodedCommand private-script-text'))) const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', @@ -110,7 +111,7 @@ describe('session/openWorkspacePath', () => { await expect(remote.openWorkspacePath({ path: 'result.html' })) .resolves.toMatchObject({ ok: false, - error: { code: 'gateway/internal', message: 'path open failed: desktop unavailable' }, + error: { code: 'gateway/internal', message: 'path open failed' }, }) const aborted = new AbortController() @@ -139,7 +140,7 @@ describe('session/openWorkspacePath', () => { await expect(controller.openWorkspacePath({ path: 'second.html', }, new AbortController().signal)).rejects.toMatchObject({ - code: 'gateway/internal', message: 'path open failed: desktop unavailable', + code: 'gateway/internal', message: 'path open failed', }) }) }) @@ -156,7 +157,7 @@ it('reports Host file-manager metadata and dispatches reveal separately from def expect(controller.workspaceDesktop()).toMatchObject({ available: true, name: expect.any(String) as string }) const signal = new AbortController().signal await controller.openWorkspacePath({ path: '/workspace/report.txt', action: 'reveal' }, signal) - expect(revealPath).toHaveBeenCalledWith('/workspace/report.txt', signal) + expect(revealPath).toHaveBeenCalledWith(resolve('/workspace/report.txt'), signal) expect(openPath).not.toHaveBeenCalled() } finally { await ctx.fiber.dispose() } }) @@ -186,7 +187,7 @@ it.each(['open', 'reveal'] as const)('rejects an unmapped remote path before nat defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, revealPath, }) await expect(controller.openWorkspacePath({ path: '/remote/report.html', ...(action === 'reveal' ? { action } : {}) }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' }) + .rejects.toMatchObject({ code: 'gateway/bad-request', message: 'Path has no verified Host path' }) expect(openPath).not.toHaveBeenCalled() expect(revealPath).not.toHaveBeenCalled() }) @@ -200,7 +201,7 @@ it('rejects a filesystem mapping that resolves to another process path', async ( defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', openPath, }) await expect(controller.openWorkspacePath({ path: '/report.html' }, new AbortController().signal)) - .rejects.toMatchObject({ code: 'gateway/internal', message: 'path open failed: Path has no verified Host path' }) + .rejects.toMatchObject({ code: 'gateway/bad-request', message: 'Path has no verified Host path' }) expect(openPath).not.toHaveBeenCalled() }) @@ -213,5 +214,61 @@ it('dispatches default-app opening to the association adapter', async () => { }) const signal = new AbortController().signal await controller.openWorkspacePath({ path: '/report.html' }, signal) - expect(open).toHaveBeenCalledWith('/report.html', signal) + expect(open).toHaveBeenCalledWith(resolve('/report.html'), signal) +}) + + +it('uses the same path verification for handler queries and explicit application opening', async () => { + const ctx = await context() + const applications = [{ id: '/Applications/Music.app', name: 'Music', default: true, icon: null }] + const fileApplications = vi.fn(async () => applications) + const openFileApplication = vi.fn(async () => {}) + const controller = createSessionTestController(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, + fileApplications, openFileApplication, + }) + const signal = new AbortController().signal + expect(await controller.workspacePathApplications({ path: '/file.mp3' }, signal)).toEqual(applications) + await controller.openWorkspacePath({ path: '/file.mp3', application: applications[0]!.id }, signal) + expect(openFileApplication).toHaveBeenCalledWith(expect.stringContaining('file.mp3'), applications[0]!.id, signal) + const mapping = vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + onTestFinished(() => { mapping.mockRestore() }) + await expect(controller.workspacePathApplications({ path: '/remote.mp3' }, signal)).rejects.toThrow('verified Host path') + await expect(controller.openWorkspacePath({ path: '/remote.mp3', application: applications[0]!.id }, signal)).rejects.toThrow('verified Host path') + expect(fileApplications).toHaveBeenCalledOnce() + expect(openFileApplication).toHaveBeenCalledOnce() +}) + + +it('avoids desktop queries when unavailable and rejects an empty query path', async () => { + const fileApplications = vi.fn(async () => []) + const unavailable = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: false, fileApplications, + }) + expect(await unavailable.workspacePathApplications({ path: '/file.mp3' }, new AbortController().signal)).toEqual([]) + const available = createSessionTestController(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, + }) + await expect(available.workspacePathApplications({ path: '' }, new AbortController().signal)).rejects.toMatchObject({ code: 'gateway/bad-request' }) + expect(fileApplications).not.toHaveBeenCalled() +}) + + +it('returns bounded query failures and classifies cancellation through the Remote', async () => { + const lifetime = new AbortController() + const fileApplications = vi.fn() + .mockRejectedValueOnce(new Error('Command failed: osascript -e private-script-text')) + .mockImplementationOnce(async () => { + lifetime.abort() + throw new Error('native query stopped') + }) + const remote = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/default', nativeOpen: true, fileApplications, + }) + await expect(remote.workspacePathApplications({ path: '/file.mp3' })).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/internal', message: 'file application query failed' }, + }) + await expect(remote.workspacePathApplications({ path: '/file.mp3' }, lifetime.signal)).resolves.toMatchObject({ + ok: false, error: { code: 'gateway/cancelled' }, + }) }) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts index fc53358241..d4061def11 100644 --- a/packages/api/session-controller/tests/sessions-service.client.spec.ts +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -461,7 +461,7 @@ describe('scope tree', () => { using reference = b.svc.retainAgentScope(sid('s-early')) const scoped = reference.binding.ctx expect(scopeOf(scoped)).toBe('s-early') - b.svc.handleControlFrame({ type: 'baseline', value: { jobs: {}, projections: {} } }) + b.svc.handleControlFrame({ type: 'baseline', value: { projections: {} } }) await feedList(b, []) expect(b.svc.scope(sid('s-early'))).toBe(scoped) reference.release() diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index b4d3db8198..f0abf92ca0 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -1,4 +1,5 @@ /** Test-only direct Remote face over the Session Controller's internal controllers. */ +import type { SessionControllerInternals } from '../src/index.ts' import { SessionLogOffset } from '@deepseek-ai/dsh-session' import type { Context } from '@deepseek-ai/cordis' @@ -48,6 +49,7 @@ import type { SessionListValue, SessionOpenWorkspacePathRequest, SessionOpenWorkspacePathValue, + SessionWorkspacePathApplication, SessionPage, SessionPageRequest, SessionPromptRequest, @@ -65,6 +67,9 @@ import type { /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { + workspacePathApplications( + request: { readonly path: string }, signal?: AbortSignal, + ): Promise> canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> @@ -94,6 +99,8 @@ export interface TestSessionRemoteDefaults { readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly fileApplications?: SessionControllerInternals['fileApplications'] + readonly openFileApplication?: SessionControllerInternals['openFileApplication'] readonly revealPath?: (path: string, signal: AbortSignal) => Promise readonly canOpenPath?: () => boolean } @@ -288,6 +295,8 @@ function installControllers( }, { ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.fileApplications === undefined ? {} : { fileApplications: defaults.fileApplications }, + ...defaults.openFileApplication === undefined ? {} : { openFileApplication: defaults.openFileApplication }, ...defaults.revealPath === undefined ? {} : { revealPath: defaults.revealPath }, ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, }, @@ -334,6 +343,9 @@ export function createSessionTestRemote( ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { + workspacePathApplications: (request, signal = new AbortController().signal) => remoteResult( + () => direct.workspacePathApplications(request, signal), signal, + ), canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts index 45e6c2b039..b0efb1b9bb 100644 --- a/packages/api/session-controller/tests/transport.client.spec.ts +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -666,10 +666,10 @@ describe('Session Client stream adapters', () => { it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { jobs: {}, projections: {} }, + value: { projections: {} }, } const update: SessionControlFrame = { - type: 'jobs', sessionId: 'session-1' as never, jobs: [], + type: 'projection', sessionId: 'session-1' as never, key: 'title', value: 'updated', seq: 1, } const remote = new ScriptedSessionRemote([], [], [baseline, update]) const accept = vi.fn<(frame: SessionControlFrame) => void>() @@ -701,7 +701,7 @@ describe('Session Client stream adapters', () => { const baseline: SessionControlFrame = { type: 'baseline', - value: { jobs: {}, projections: {} }, + value: { projections: {} }, } const carrierFailed = vi.fn() const failed = vi.fn() diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json index 3bb07893e2..a1375420ca 100644 --- a/packages/api/session-controller/tsconfig.client.json +++ b/packages/api/session-controller/tsconfig.client.json @@ -28,6 +28,7 @@ { "path": "../../util/crypto" }, { "path": "../../util/workspace-path" }, { "path": "../../workspace/workspace" }, - { "path": "../../typert/protocol" } + { "path": "../../typert/protocol" }, + { "path": "../../util/native-command" } ] } diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 72f81ef676..4a055c8fd5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -102,6 +102,10 @@ - id: session-controller name: '@deepseek-ai/dsh-api-session-controller' + # One background job's observation record streamed over Typert Remote; + # the roster rides the session control stream above. + - id: job-controller + name: '@deepseek-ai/dsh-api-job-controller' # Workspace file service: bounded read, directory listing, and the # agent-write change feed inside the session workspace root. - id: terminal-controller @@ -324,6 +328,12 @@ - id: ui-reference name: '@deepseek-ai/dsh-client-ui-reference' + + # Background jobs: the session-header roster with on-demand streaming + # record panels over the session job-output service. + - id: ui-jobs + name: '@deepseek-ai/dsh-client-ui-jobs' + # Read-only active Schedule catalog. The shipped Web graph resolves the # client package but leaves it disabled; the explicit Schedule overlay # enables this same row together with the host Schedule services. @@ -331,10 +341,6 @@ name: '@deepseek-ai/dsh-client-ui-schedule' disabled: true - # Background jobs: the session-header list over the jobsBySession mirror. - - id: ui-jobs - name: '@deepseek-ai/dsh-client-ui-jobs' - # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index cc00c61d15..f40797d498 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index f77acedd8d..f968140311 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -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/README.md -README.md: 3d8e52fe6dfc3e517c09f772e63c073957e41cd9 -README.zh.md: 4a547889b9a12af7e65a671bef9176075d55ebc1 +README.md: a7e1397b4b62d883cbe02c4ec23afe35b9f7be9e +README.zh.md: 01be68b480626aa77bfb361f4dda28c2d1cdfbd9 diff --git a/packages/client/README.md b/packages/client/README.md index 3d8e52fe6d..a7e1397b4b 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -62,8 +62,8 @@ The kernel packages boot and serve the page; the UI feature packages present it. | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | | [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | +| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs with on-demand streaming record panels | — | | [`ui-schedule/`](ui-schedule/README.md) | Lists the current Session's active reminders in a read-only header catalog | — | -| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header | — | | [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | | [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control | — | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 4a547889b9..01be68b480 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -62,8 +62,8 @@ kind: "package-group" | [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | | [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | | [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent 导航、子级 transcript(文本记录)状态与内联引用 | — | +| [`ui-jobs/`](ui-jobs/README.zh.md) | 列出本会话的后台任务,并提供按需展开的流式输出面板 | — | | [`ui-schedule/`](ui-schedule/README.zh.md) | 在只读标题栏目录中列出当前会话中生效的提醒 | — | -| [`ui-jobs/`](ui-jobs/README.zh.md) | 在会话标题栏列出当前会话的后台任务 | — | | [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | | [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | | [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件 | — | diff --git a/packages/client/locale/tests/language-row.client.spec.tsx b/packages/client/locale/tests/language-row.client.spec.tsx index 5068b90218..776542c0f2 100644 --- a/packages/client/locale/tests/language-row.client.spec.tsx +++ b/packages/client/locale/tests/language-row.client.spec.tsx @@ -20,7 +20,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} }) + { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index b8468c0957..5e9592ba70 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -61,7 +61,7 @@ function styleInjectionModule( * Everything else under @deepseek-ai/* is either a module-table entry * (external) or a leak the purity gate rejects. */ -export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ +export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-presets\/display$|@deepseek-ai\/dsh-spill-policy\/notice$)/ /** * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below diff --git a/packages/client/ui-approval/src/client/contract/slots.ts b/packages/client/ui-approval/src/client/contract/slots.ts index 99296e0c92..875fe5ea36 100644 --- a/packages/client/ui-approval/src/client/contract/slots.ts +++ b/packages/client/ui-approval/src/client/contract/slots.ts @@ -65,6 +65,9 @@ export type ApprovalDecision = 'allowed-once' | 'rejected' let nextApprovalKey = 0 +/** Domain discriminator literal carried by {@link PendingApproval.kind}. */ +export type ApprovalInteractionKind = 'approval' + /** One answerable Client presentation of a pending Host waterfall. */ export class PendingApproval { /** Domain discriminator used by Session pending-interaction consumers. */ diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 5ce716a2ae..0fad5492e0 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -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-attachment/README.md -README.md: 0765068c73c8ca3cf79b8b23a54629669625ca2e -README.zh.md: 5976acfda5b605dd85ba3d43e18a83af4d9d58e0 +README.md: 5a3e697189936a79df19ff9b976f06e0dd6191fb +README.zh.md: 4168650770f42e7b94146041f37ffc440a5ec52e diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 0765068c73..5a3e697189 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -39,7 +39,7 @@ Trajectory attachment rows request 48px square thumbnails that contain the compl ### Drop overlay -While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject. +While a file drag is over the page, the full-viewport overlay announces the drop: illustration, title, and a limits line when drops are accepted. The overlay only shows state — the owner's document-level listeners decide accept or reject, and the drop handler reports which dropped members are directories through the entry API so the owner can cite or refuse them. ----- diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 5976acfda5..4168650770 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -39,7 +39,7 @@ Trajectory 附件行使用 48px 方形缩略图,完整缩放图片而不裁剪 ### 拖放遮罩 -文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态——是否接受由持有方的文档级监听器决定。 +文件拖到页面上方时,全视口遮罩显示拖放提示,包括插画和标题;接受拖放时还会显示一行限制说明。遮罩只呈现状态,是否接受由持有方的文档级监听器决定,拖放处理器通过 entry API 报告放下的成员中哪些是文件夹,以便持有方引用或拒绝它们。 ----- diff --git a/packages/client/ui-attachment/src/client/drop-events.ts b/packages/client/ui-attachment/src/client/drop-events.ts index e04cbd43d0..2f3da69372 100644 --- a/packages/client/ui-attachment/src/client/drop-events.ts +++ b/packages/client/ui-attachment/src/client/drop-events.ts @@ -1,6 +1,24 @@ /** Document drag-and-drop listeners owned by one mounted attachment view. */ import type { ComposerAttachmentsProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +/** + * Members of a drop that are directories. The `File` a directory drop yields + * is indistinguishable from an empty file, so the entry API is the only + * source of that fact; browsers without it report no directories. + */ +function droppedDirectories(dataTransfer: DataTransfer, files: readonly File[]): ReadonlySet { + const directories = new Set() + let fileIndex = 0 + for (const item of dataTransfer.items) { + if (item.kind !== 'file') continue + const file = files[fileIndex++] + if (typeof item.webkitGetAsEntry !== 'function') continue + if (item.webkitGetAsEntry()?.isDirectory !== true) continue + if (file !== undefined) directories.add(file) + } + return directories +} + /** * Install one attachment view's file-drop listeners. * @param canAcceptDrop - whether this view accepts the dropped files. @@ -49,7 +67,10 @@ export function installDocumentDropEvents( if (dataTransfer === null) return event.preventDefault() reset() - if (canAcceptDrop) onAddFiles([...dataTransfer.files]) + if (canAcceptDrop) { + const files = [...dataTransfer.files] + onAddFiles(files, droppedDirectories(dataTransfer, files)) + } } document.addEventListener('dragenter', onDragEnter) document.addEventListener('dragover', onDragOver) diff --git a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx index c0d0920c52..057e29dc9c 100644 --- a/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx +++ b/packages/client/ui-attachment/tests/composer-attachments.client.spec.tsx @@ -103,20 +103,49 @@ describe('ComposerAttachments', () => { expect(view.queryByRole('status')).toBeNull() const image = attachment('dropped').file - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + const dataTransfer = { types: ['Files'], files: [image], items: [], dropEffect: 'none' } expect(fireEvent.dragEnter(document.body, { dataTransfer })).toBe(false) expect(view.getByRole('status').textContent).toContain('文件或图片拖动到此处即可添加') expect(view.getByRole('status').textContent).toContain('图片限制:最多 20 张,每张 5MB') expect(fireEvent.dragOver(document.body, { dataTransfer })).toBe(false) expect(dataTransfer.dropEffect).toBe('copy') expect(fireEvent.drop(document.body, { dataTransfer })).toBe(false) - expect(onAddFiles).toHaveBeenCalledWith([image]) + expect(onAddFiles).toHaveBeenCalledWith([image], new Set()) expect(view.queryByRole('status')).toBeNull() }) + it('reports dropped directories from the entry API beside the dropped files', () => { + const onAddFiles = vi.fn() + render() + const folder = new File([], 'project') + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + const entry = (isDirectory: boolean | null, file: File) => ({ + kind: 'file', getAsFile: () => new File([], file.name), webkitGetAsEntry: () => (isDirectory === null ? null : { isDirectory }), + }) + fireEvent.drop(document.body, { + dataTransfer: { + types: ['Files'], + files: [folder, note], + items: [entry(true, folder), entry(false, note), { kind: 'string', getAsFile: () => null }], + dropEffect: 'none', + }, + }) + expect(onAddFiles).toHaveBeenCalledWith([folder, note], new Set([folder])) + // An entry API that answers nothing, or a browser without it, reports no directories. + fireEvent.drop(document.body, { + dataTransfer: { + types: ['Files'], + files: [note], + items: [entry(null, note), { kind: 'file', getAsFile: () => note }, entry(true, folder)], + dropEffect: 'none', + }, + }) + expect(onAddFiles).toHaveBeenLastCalledWith([note], new Set()) + }) + it('tracks nested file drags and clears an aborted drag', () => { const view = render() - const dataTransfer = { types: ['Files'], files: [], dropEffect: 'none' } + const dataTransfer = { types: ['Files'], files: [], items: [], dropEffect: 'none' } fireEvent.dragLeave(document.body, { dataTransfer: { types: ['text/plain'], files: [], dropEffect: 'none' }, }) @@ -144,7 +173,7 @@ describe('ComposerAttachments', () => { const onAddFiles = vi.fn() const view = render() const image = attachment('blocked').file - const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'copy' } + const dataTransfer = { types: ['Files'], files: [image], items: [], dropEffect: 'copy' } fireEvent.dragEnter(document.body, { dataTransfer }) expect(view.getByRole('status').textContent).toBe('当前无法添加文件或图片') fireEvent.dragOver(document.body, { dataTransfer }) diff --git a/packages/client/ui-attachment/tests/message-image.client.spec.tsx b/packages/client/ui-attachment/tests/message-image.client.spec.tsx index ee14237ad5..21db2ee64e 100644 --- a/packages/client/ui-attachment/tests/message-image.client.spec.tsx +++ b/packages/client/ui-attachment/tests/message-image.client.spec.tsx @@ -327,6 +327,8 @@ describe('ImageGallery', () => { useTrajectory, useInput, inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: vi.fn(), addAttachments: vi.fn(() => true), removeAttachment: vi.fn(), diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index 1ee50e0265..db60f029a9 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -207,7 +207,7 @@ const compaction = (over: Partial = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} }) + { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} }) return bindSnapshotSelector(store) } @@ -395,6 +395,8 @@ function makeHarness( }, useInput: (() => { throw new Error('unused') }), inputActions: { + captureInsertion: () => ({ start: 0, end: 0, draftRev: 0 }), + insertText: () => false, setDraft: () => {}, addAttachments: () => true, removeAttachment: () => {}, diff --git a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx index 6d578cb38f..98154b74cc 100644 --- a/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/transcript-view-row.client.spec.tsx @@ -17,7 +17,7 @@ afterEach(cleanup) function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, + ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, })) } diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index a004571d35..92dffe85b0 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -90,7 +90,7 @@ "path": "../ui-session" }, { - "path": "../ui-sidebar-browser" + "path": "../ui-sidebar-browser/tsconfig.client.json" }, { "path": "../ui-sidebar-right" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index b871029e8d..2f52a3e505 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 16e821ed4caabae491d552a823bd69a2e976e2bd -README.zh.md: 7316b00c69ca74284b81678082d93e870dee6bbe +README.md: 31b35ef7460c9fecd119e32ae5f696f55585a726 +README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 16e821ed4c..31b35ef746 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -62,7 +62,7 @@ Active transcripts in the main occurrence expose content-width drag handles in t The resident composer survives no-Session and Session transitions. Whitespace hides its placeholder; a whitespace-only draft without attachments cannot be sent. The no-Session state keeps the same composer surface mounted but inert while the Workspace picker connects a blank Session. The surface is a shell-owned Lexical editor: reference chips are atomic decorator nodes carrying the owner's serialization identity (submission expands them through the owner codec), claimed slash commands stay styled leading text, folder text references carry the folder glyph as an icon prefix, and the draft's clipboard projection is mirrored into the per-Session Conversation store. QueueDock reads `next-turn` directly from the Session `inbox` projection, including cold recovered messages. Queue operations address exact queue occurrences through the scoped `ctx.conversation` service; queue previews render sent text through the shared inline reference projection from `ui-primitives` (wire session forms fold to their label) and show local or durable images and files in original attachment order. Images use thumbnails; files use compact name-and-size cards. An edit exposes the literal sent text, and durable thumbnails resolve through the session image URL cache. Busy Enter behavior is stored in the Host-backed `ui-conversation` settings namespace. The composer keymap arbitrates the trigger menu's keys through the slash pipeline — Tab settles the highlighted completion (or drills a drillable one), Escape and Shift+Tab leave the menu without settling — and leaves every other key to the editor. An overlay that takes the keyboard hands it back through `SessionInput.focus()`, which rides Lexical's own focus so the caret returns where the draft left it rather than at the start. -Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. +Default sends commit optimistically: Enter clears the draft, occurrence table, and undo history in the same transaction, keeps the composer in `plain`, and runs the send as a detached attempt, so typing and further sends continue during the flight. `sendSession` registers a Session submission echo (`session.beginSubmission`) with the delivery mode before serializing, preserving selected image and file order in `pendingSubmissions`; Session derives the placement from that mode and its current running state, so idle sends use the transcript, busy Queue sends use QueueDock, and busy Steer sends use the pending-steering surface. It then yields one paint, encodes images through the browser's native `FileReader` data-URL path, and cites staged file receipts. Command submissions use the same receipts for generic files, so sending `/goal` or `/plan` never reads those browser files again. The prompt reuses the submission `requestId`; queue and history observation by that `rpcId` retires the echo once. Concurrent failures are restored together in submission order until the user edits the restored content; command submissions keep the frozen `submitting` phase. Detached attempts retain their attachment ids through admission and Session scope disposal. An observed retirement immediately exposes each image preview through the durable cache, replaces it with the canonical URL after fetching the admitted attachment, revokes each URL after its use ends, and releases file cards. Selected generic files enter one FIFO background-upload queue; `maxConcurrentFileUploads` defaults to two active Worker transports, the Conversation service retains queued and active operations plus byte progress across Session navigation, and removing a draft skips its queued transfer or aborts its active transport. Where the browser shell exposes `__DSH_HOST_PATHS__` (the Desktop application), dropped or pasted folders and dropped, picked, or pasted non-image files with a real path become `@path` chips; images keep uploading. Drag-and-drop and paste identify directories through the browser entry API; when that API is absent or returns no entry, pasted items retain ordinary file handling. The file picker cannot select directories. References require the `ui-reference` plugin and the original paths to remain readable by the model's file tools. Paths inside the workspace are relative; others remain absolute. The whole batch is validated before insertion, retains source order and selected text, and uses whitespace-separated, closed-quote mentions. A browser without the bridge refuses dropped or pasted folders; a Desktop folder without a reported path is rejected separately. Continuable subagents disable attachment intake and skip local echoes because their transport does not preserve the browser request id. Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. @@ -120,6 +120,10 @@ try { The selector must be a pure function of the owner currency. Its non-null return is delivered to the component as `matched`; `PropsRuntime<'conversation.composer'>` supplies the standard Session and global props. Chain order remains ascending `priority`, then registration order, and the first non-null selector wins. The shell keeps the default composer mounted beneath a takeover. Request state, listeners, response encoding, and any request-specific child slots belong to the business package; they are not carried by `SessionSnapshot` or declared by this core package. +`InputActions.captureInsertion()` captures the draft selection and revision; `insertText(text, span)` inserts one undoable plain-text edit only while that revision is current and the editor permits editing. Asynchronous consumers retain rejected results for user action. + +`conversation.input.activity` hosts one control between the model selector and Send. Its `onActiveChange` callback expands that control across the toolbar and hides ordinary accessory controls and the context meter while preserving the editor and submit action. Closing the activity restores those controls with context details closed. The empty hero dock remains collapsed when it has no content. The occupant releases expansion on unmount and owns any activity-specific feedback. + ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 7316b00c69..1fb648bf95 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -62,7 +62,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 常驻 composer 在无 Session 与有 Session 之间保持挂载。输入空白字符会隐藏占位提示;没有附件的纯空白草稿无法发送。无 Session 时,同一个编辑器表面保持 inert,Workspace picker 连接 blank Session。该表面是 shell 所有的 Lexical 编辑器:引用 chip 是携带 owner 序列化身份的原子 decorator 节点(提交时经 owner codec 展开),已认领的 slash command 保持为带样式的行首文本,文件夹文本引用以图标前缀携带文件夹图形,草稿的剪贴板投影镜像到逐 Session Conversation store。QueueDock 直接从 Session 的 `inbox` 投影读取 `next-turn`,包含从冷状态恢复的消息。Queue 操作通过 scoped `ctx.conversation` service 寻址准确的 queue occurrence;queue 预览经 `ui-primitives` 的共享行内引用投影渲染已发送文本(wire 会话形式折叠为其标签),并按原始附件顺序展示本地或持久化的图片和文件。图片使用缩略图,文件使用紧凑的名称与大小卡片。编辑态展示字面发送文本,持久化缩略图通过会话图片 URL 缓存解析。繁忙时 Enter 行为保存在 Host-backed `ui-conversation` settings namespace。 composer 键盘映射经斜杠流水线裁决触发菜单的按键——Tab 确认高亮补全项(可下钻项则下钻),Escape 与 Shift+Tab 离开菜单且不选定——其余按键交给编辑器自身。 接管键盘的浮层通过 `SessionInput.focus()` 把键盘还回来,该路径走 Lexical 自己的 focus,因此光标回到草稿原来的位置而不是开头。 -默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 +默认发送采用乐观提交:Enter 在同一事务里清空草稿、occurrence 表和撤销历史,composer 保持 `plain`,发送作为 detached attempt 运行,发送期间可以继续输入和提交。`sendSession` 在序列化之前用投递模式注册 Session 提交回显(`session.beginSubmission`),并在 `pendingSubmissions` 中保留图片与文件的选择顺序;Session 根据该模式与当前运行状态推导位置,因此空闲发送进入 transcript(文本记录),繁忙时 Queue 进入 QueueDock,繁忙时 Steer 进入 pending-steering 区域。随后让出一帧,图片经浏览器原生 `FileReader` data-URL 路径编码,文件则引用已暂存凭证。命令提交也用同一凭证表示通用文件,因此发送 `/goal` 或 `/plan` 时不会再次读取这些浏览器文件。提示词复用提交 `requestId`;queue 或历史以同一 `rpcId` 被观察后,回显只退休一次。多个并发发送失败时,在用户编辑还原内容之前按提交顺序合并还原;命令提交保持冻结的 `submitting` 阶段。Detached attempt 持有附件 id,直到 admission 完成或 Session scope 销毁。回显以 observed 退休时,durable 图片缓存立即公开每个预览 URL,读取 admitted 附件后用规范化 URL 替换预览,并在各 URL 停止使用后撤销,同时释放文件卡。选中的通用文件进入同一个先进先出的后台上传队列;`maxConcurrentFileUploads` 默认允许两个 Worker transport 同时运行,Conversation 服务在切换 Session 时继续持有排队和运行中的传输操作及字节进度,移除草稿会跳过排队中的传输或中止正在运行的传输。浏览器 shell 暴露 `__DSH_HOST_PATHS__` 时(桌面应用),拖入或粘贴的文件夹以及拖入、选择或粘贴的带真实路径的非图片文件会成为 `@路径` chip;图片仍然上传。拖放和粘贴通过浏览器 entry API 识别目录;该 API 不可用或没有返回 entry 时,粘贴项沿用普通文件处理。文件选择器不能选择目录。引用需要启用 `ui-reference` 插件,原路径也必须仍可由模型的文件工具读取。工作区内的路径使用相对形式,其他路径保留绝对形式。整批文件先校验再插入,保留来源顺序和已选中的文字,引用之间有空白分隔,含空格的路径使用闭合引号。没有该桥的浏览器会拒绝拖入或粘贴的文件夹,桌面端无法获取文件夹路径时单独报错。continuable 子代理禁用附件入口,也不创建本地回显,因为其 transport 不保留浏览器 request id。 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到提示词确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 @@ -120,6 +120,10 @@ try { selector 必须是 owner currency 的纯函数。非 null 返回值作为 `matched` 传给组件;`PropsRuntime<'conversation.composer'>` 提供标准 Session 与 global props。Chain 顺序仍按 `priority` 升序,再按注册顺序;首个返回非 null 的 selector 获选。Shell 会在 takeover 下保持默认 composer 挂载。Request 状态、listener、response encoding 和任何 request-specific child slot 都属于业务 package,不进入 `SessionSnapshot`,也不由 core 包声明。 +`InputActions.captureInsertion()` 捕获草稿选区与版本;`insertText(text, span)` 仅在版本未变且编辑器允许编辑时,插入一次可撤销的纯文本编辑。异步消费者在插入被拒绝后负责保留结果,等待用户操作。 + +`conversation.input.activity` 在模型选择器与发送按钮之间承载一个控件。其 `onActiveChange` 回调将控件展开至整条工具栏并隐藏普通辅助控件和上下文用量按钮,同时保留编辑器与提交按钮。关闭活动后恢复这些控件,上下文详情保持关闭。首页输入框下方没有内容时,该区域保持收起。占用者在卸载时释放展开状态,并拥有活动专属反馈。 + ## 模型体验 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 8a92b63714..e5b6bcdf2b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -80,6 +80,7 @@ "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 07d754d873..760f7ac07b 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -18,8 +18,11 @@ import type { ConversationSessionInjected, DraftFileUploads, } from './contract/slots.ts' import type { InputNotice } from './contract/input.ts' +import type { ReferenceInsert } from './contract/draft-editor.ts' import { createConversationStore, readConversationViewPreference } from './stores.ts' -import { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' +import { formatFileMention } from '@deepseek-ai/dsh-file-reference/grammar' +import { relativizeToCwd, workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import { ConversationController, UnsupportedImageMediaTypeError, isImageMediaType } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './contract/composer-blocks.ts' @@ -87,6 +90,21 @@ const ABSENT_FILE_UPLOADS = { subscribe: () => () => {}, } +/** + * Browser-shell bridge reporting the harness-host path of a picked file. The + * Desktop preload exposes it on the application document; a served Web page + * has none, so every non-image file uploads there. + */ +interface HostPathBridge { + /** Absolute harness-host path of one picked file, or empty when the shell has none for it. */ + pathFor(file: File): string +} + +/** The shell-installed bridge, when this document runs inside the Desktop application. */ +function hostPathBridge(): HostPathBridge | undefined { + return (globalThis as { __DSH_HOST_PATHS__?: HostPathBridge }).__DSH_HOST_PATHS__ +} + interface WorkspaceNavigation { openSession(sessionId: SessionId): void openWorkspace( @@ -368,6 +386,7 @@ export function apply(ctx: Context, config: Config = Config({})): void { 'conversation.input.plan': { kind: 'single', scope: 'session' }, 'conversation.input.right': { kind: 'list', scope: 'session' }, 'conversation.input.model': { kind: 'single', scope: 'session' }, + 'conversation.input.activity': { kind: 'single', scope: 'session' }, 'conversation.composer.dock': { kind: 'list', scope: 'session' }, }, inject: (sessionId: SessionId | undefined): ComposerBarInjected => { @@ -392,14 +411,41 @@ export function apply(ctx: Context, config: Config = Config({})): void { const conversation = concreteConversation(ctx) const shell = inputHub.shell(sessionId) const inputTriggers = inputHub.inputTriggers(sessionId) + const bridge = hostPathBridge() return { keyboard: shell, - addFiles: (files) => { + addFiles: (files, directories = new Set()) => { if (sessions.binding(sessionId) === undefined) return t('file.sessionUnavailable') + if (shell.snapshot.phase === 'adjudicating' || shell.snapshot.phase === 'submitting') { + return t('attachment.dropBlocked') + } + const uploads: File[] = [] + const references: ReferenceInsert[] = [] + const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd + for (const file of files) { + const directory = directories.has(file) + if (bridge === undefined && directory) return t('attachment.directoryDesktopOnly') + const path = bridge?.pathFor(file) ?? '' + if (directory && path === '') return t('attachment.pathUnavailable') + if (path === '' || (!directory && isImageMediaType(file.type))) { + uploads.push(file) + continue + } + const relative = relativizeToCwd(path, cwd) + // A completed directory chip needs closed quotes; the directory grammar keeps them open for drill. + const mention = formatFileMention({ path: directory ? `${relative}/` : relative, kind: 'file' }, false) + if (mention === undefined) return t('attachment.pathUnsupported') + const label = workspaceTitleOf(path) || file.name + references.push({ + source: 'reference', ref: mention, label: directory ? `${label}/` : label, + appearance: directory ? 'folder' : 'file', clipboardText: mention, + }) + } try { - const drafts = conversation.createDrafts(sessionId, files) - if (!shell.addAttachments(drafts.map(draft => draft.id))) { + const drafts = conversation.createDrafts(sessionId, uploads) + if (!shell.addFiles(references, drafts.map(draft => draft.id))) { conversation.releaseDraftAttachments(drafts) + return t('attachment.dropBlocked') } return null } catch (error: unknown) { diff --git a/packages/client/ui-conversation/src/client/contract/input.ts b/packages/client/ui-conversation/src/client/contract/input.ts index b4cd6b9bdf..4036736374 100644 --- a/packages/client/ui-conversation/src/client/contract/input.ts +++ b/packages/client/ui-conversation/src/client/contract/input.ts @@ -220,6 +220,15 @@ export interface SessionInputResolver { * paste/…) stay InputBar-private and never ride this face. */ export interface InputActions { + /** @returns a revision-guarded copy of the current editor selection. */ + captureInsertion(): TokenSpan + /** + * Insert asynchronous text without replacing subsequent edits or reference chips. + * @param text - plain text to insert in one undo step. + * @param span - captured selection and draft revision. + * @returns false when the draft changed or submission locked the editor. + */ + insertText(text: string, span: TokenSpan): boolean /** Replace the whole draft (persisted-draft seed and programmatic writes). */ setDraft(text: string): void /** Append ordered browser-owned attachment ids; busy admission phases refuse. */ diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 023babf26e..00f8db03b8 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -60,8 +60,12 @@ export interface ComposerAttachmentsOwnerProps { attachments: readonly ComposerAttachment[] /** Whether a document-level file drop may add attachments now. */ canAcceptDrop: boolean - /** Add one dropped batch through the composer's validation path. */ - onAddFiles: (files: readonly File[]) => void + /** + * Add one dropped batch through the composer's validation path. + * @param files - dropped, pasted, or picked browser files in source order. + * @param directories - members of `files` the drop source identified as directories. + */ + onAddFiles: (files: readonly File[], directories?: ReadonlySet) => void /** Remove one draft attachment through the Conversation service. */ onRemoveAttachment: (id: DraftAttachmentId) => void /** Current per-draft upload states for file-kind attachments. */ @@ -197,6 +201,8 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { 'conversation.input.left': { kind: 'list'; scope: 'session' } /** Compact controls before the composer submit action. */ 'conversation.input.right': { kind: 'list'; scope: 'session' } + /** Compact action after the model selector; it can expand across the toolbar while retaining the editor and submit action. */ + 'conversation.input.activity': { kind: 'single'; scope: 'session'; owner: InputActivityOwnerProps } /** Resident composer body, including the no-Session inert state. */ 'conversation.composer.bar': { kind: 'single'; scope: 'session-maybe'; owner: ComposerBarOwnerProps } /** Optional draft-attachment rail and drop target. */ @@ -366,7 +372,14 @@ export interface ComposerBarOwnerProps { /** Package-private operations injected into the resident composer bar. */ export interface ComposerBarInjected { keyboard: ComposerKeyboard | undefined - addFiles: ((files: readonly File[]) => string | null) | undefined + /** + * Register one picked batch; resolves to the rejection copy or null. Where + * the browser shell reports host paths (the Desktop application), files + * and folders with a real path become `@path` references in the draft + * instead of uploads; `directories` names the members the drop source + * identified as directories. + */ + addFiles: ((files: readonly File[], directories?: ReadonlySet) => string | null) | undefined removeAttachment: ((id: DraftAttachmentId) => void) | undefined resolveDraftAttachments: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined /** Restart one failed file upload; absent without a session. */ @@ -393,6 +406,12 @@ export interface InputControlOwnerProps { locked: boolean } +/** A toolbar activity hides ordinary accessory controls while expanded; its occupant must release expansion on unmount. */ +export interface InputActivityOwnerProps extends InputControlOwnerProps { + /** @param active - whether the occupant needs the toolbar width before the submit action. */ + onActiveChange: (active: boolean) => void +} + /** Full props of the resident composer bar. */ export type ComposerBarProps = PropsRuntime<'conversation.composer.bar'> @@ -400,7 +419,7 @@ export type ComposerBarProps = | 'conversation.input.attachments' | 'conversation.input.overlay' | 'conversation.input.permission' | 'conversation.input.left' | 'conversation.input.plan' - | 'conversation.input.right' | 'conversation.input.model' + | 'conversation.input.right' | 'conversation.input.model' | 'conversation.input.activity' | 'conversation.composer.dock' > & InjectFace diff --git a/packages/client/ui-conversation/src/client/input/editor/keymap.ts b/packages/client/ui-conversation/src/client/input/editor/keymap.ts index 42c217caa0..7291f2c25a 100644 --- a/packages/client/ui-conversation/src/client/input/editor/keymap.ts +++ b/packages/client/ui-conversation/src/client/input/editor/keymap.ts @@ -34,8 +34,12 @@ export interface ComposerKeymapHandlers { canSubmit(): boolean /** The Enter gesture after every guard passed; `accelerated` = Ctrl/Cmd held. */ submit(accelerated: boolean): void - /** Pasted files (image intake). */ - intakeFiles(files: readonly File[]): void + /** + * Pasted files with directory metadata supplied by the clipboard entry API. + * @param files - browser files in clipboard order. + * @param directories - known directory members; absent when no entry identifies a directory. + */ + intakeFiles(files: readonly File[], directories?: ReadonlySet): void /** Pasted plain text (sanitized insertion through the shell). */ pasteText(text: string): void } @@ -154,11 +158,18 @@ export function registerComposerKeymap(editor: LexicalEditor, handlers: Composer // deliver clipboardData on plain events. const clipboardData = (event as ClipboardEvent).clipboardData ?? null if (clipboardData === null) return false - const files = Array.from(clipboardData.items) - .filter(item => item.kind === 'file') - .map(item => item.getAsFile()) - .filter((file): file is File => file !== null) - if (files.length > 0) handlers.intakeFiles(files) + const files: File[] = [] + const directories = new Set() + for (const item of clipboardData.items) { + if (item.kind !== 'file') continue + const file = item.getAsFile() + if (file === null) continue + files.push(file) + if (typeof item.webkitGetAsEntry === 'function' && item.webkitGetAsEntry()?.isDirectory === true) { + directories.add(file) + } + } + if (files.length > 0) handlers.intakeFiles(files, directories.size === 0 ? undefined : directories) const text = clipboardData.getData('text/plain') if (text === '') { if (files.length === 0) return false diff --git a/packages/client/ui-conversation/src/client/input/editor/runtime.ts b/packages/client/ui-conversation/src/client/input/editor/runtime.ts index 2054624342..f59a6d947d 100644 --- a/packages/client/ui-conversation/src/client/input/editor/runtime.ts +++ b/packages/client/ui-conversation/src/client/input/editor/runtime.ts @@ -210,6 +210,19 @@ export class DraftEditorRuntime { return applied } + /** + * Insert an asynchronous text result as one independent undo operation. + * @param span - owner-validated insertion range. + * @param text - text sanitized with the same rules as paste. + * @returns whether the range mapped and the edit applied. + */ + insertAsyncText(span: DetectSpan, text: string): boolean { + let applied = false + const clean = text.replace(REFERENCE_PLACEHOLDER_RE, '') + this.applyEdit(() => { applied = $replaceDetectSpanWithText(span, clean) }, PASTE_TAG) + return applied + } + /** * Insert a reference chip with the existing trailing-space rule. * @param span - detect-coordinate range. @@ -228,6 +241,25 @@ export class DraftEditorRuntime { return applied } + /** + * Insert an ordered file-reference batch after the live selection without deleting it. + * @param references - validated references in source order. + * @returns whether the live insertion position accepted the batch. + */ + insertFileReferences(references: readonly ReferenceInsert[]): boolean { + if (references.length === 0) return true + let applied = false + this.applyEdit(() => { + const projection = $projectComposer(key => this.occurrenceIdOf(key)) + const at = projection.selection?.end ?? projection.detectText.length + const before = projection.detectText.slice(0, at) + const nodes = references.flatMap(ref => [$createReferenceChipNode(ref), $createTextNode(' ')]) + if (before !== '' && !/\s$/u.test(before)) nodes.unshift($createTextNode(' ')) + applied = $replaceDetectSpanWithNodes({ start: at, end: at }, nodes) + }, PASTE_TAG) + return applied + } + /** Refresh claim-token decoration after the model's claim changes. */ refreshClaimDecoration(): void { refreshClaimDecoration(this.editor) diff --git a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts index 8dcd3d8c63..89218a887c 100644 --- a/packages/client/ui-conversation/src/client/input/editor/view-binding.ts +++ b/packages/client/ui-conversation/src/client/input/editor/view-binding.ts @@ -14,7 +14,7 @@ interface DraftViewGate { running: boolean steeringAvailable: boolean busyEnter: BusyEnterBehavior - intakeFiles: (files: readonly File[]) => void + intakeFiles: (files: readonly File[], directories?: ReadonlySet) => void uploadsPending: boolean showToast: (text: string) => void t: ComposerBarProps['t'] @@ -136,7 +136,7 @@ export function installDraftKeymap( g.steeringAvailable, )) }, - intakeFiles: (files) => { gate.current.intakeFiles(files) }, + intakeFiles: (files, directories) => { gate.current.intakeFiles(files, directories) }, pasteText: (text) => { if (gate.current.machineBusy || gate.current.locked) return keyboard.paste(text) diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 4f4d0aa300..3cd956bf76 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -117,6 +117,12 @@ export class SessionInputShell implements SessionInput { } /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { + captureInsertion: () => ({ ...this.caretSpan(), draftRev: this.rev }), + insertText: (text, span) => { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting' || this.disposed) return false + if (span.draftRev !== this.rev) return false + return this.draftEditor.insertAsyncText(span, text) + }, setDraft: (text) => { this.setDraft(text) }, addAttachments: ids => this.addAttachments(ids), removeAttachment: (id) => { this.removeAttachment(id) }, @@ -215,6 +221,20 @@ export class SessionInputShell implements SessionInput { return true } + /** + * Add validated file references and attachment ids while admission is editable. + * @param references - reference chips in source order. + * @param ids - newly allocated attachment ids. + * @returns false when admission is locked or the editor refuses the insertion. + */ + addFiles(references: readonly ReferenceInsert[], ids: readonly DraftAttachmentId[]): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + if (!this.draftEditor.insertFileReferences(references)) return false + this.attachmentIds = [...this.attachmentIds, ...ids] + this.publish() + return true + } + /** * Remove one attachment id from this draft. Busy admission phases refuse, like * {@link addAttachments}: a removal landing while a command submit serializes diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index c164dc8535..8ddad82052 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -31,6 +31,9 @@ export const zh = { 'attachment.dropTitle': '文件或图片拖动到此处即可添加', 'attachment.dropDesc': '图片限制:最多 {count} 张,每张 {size}', 'attachment.dropBlocked': '当前无法添加文件或图片', + 'attachment.directoryDesktopOnly': '只有桌面端支持添加文件夹,浏览器里请添加单个文件', + 'attachment.pathUnavailable': '无法获取文件夹路径,请重新拖入', + 'attachment.pathUnsupported': '路径含有无法引用的字符,请改名后再试', 'image.pending': '待发送图片', 'image.openOriginal': '查看原图', 'image.openOriginalLabel': '{label},点击查看原图', @@ -382,6 +385,9 @@ export const en = { 'attachment.dropTitle': 'Drag files or images here to add them', 'attachment.dropDesc': 'Image limit: up to {count} images, {size} each', 'attachment.dropBlocked': 'Files and images cannot be added right now', + 'attachment.directoryDesktopOnly': 'Folders can only be added in the desktop app; add individual files in the browser', + 'attachment.pathUnavailable': 'Could not obtain the folder path; drag it in again', + 'attachment.pathUnsupported': 'The path contains characters a reference cannot carry; rename it and try again', 'image.pending': 'Pending images', 'image.openOriginal': 'View original', 'image.openOriginalLabel': '{label}, click to view original', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index 367647658e..2ef2309ccf 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -594,8 +594,12 @@ function imageMediaType(value: string): ImageMediaType { } } -/** Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). */ -function isImageMediaType(value: string): boolean { +/** + * Whether a browser-declared MIME selects the image draft path (all other files upload verbatim). + * @param value - the browser's declared MIME type. + * @returns whether the file is an accepted raster image. + */ +export function isImageMediaType(value: string): boolean { return value === 'image/png' || value === 'image/jpeg' || value === 'image/webp' || value === 'image/gif' } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 6226a18ffa..fbd07f2810 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -407,3 +407,10 @@ font-size: 12px; cursor: pointer; } + +.standardControls { display: flex; align-items: center; gap: 12px; min-width: 0; } +.tools[hidden], .standardControls[hidden] { display: none; } +.trailingActive { flex: 1; margin-left: 0; } +.activity { display: flex; align-items: center; flex: none; } +.activity:empty { display: none; } +.activityExpanded { flex: 1; min-width: 0; } diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6a4ea12740..7016ea8f9d 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -55,6 +55,8 @@ export const InputBar = memo(function InputBar({ const busyEnter = useBusyEnter(s => s) void useLexicon // hook seat stays bound by the inject compartment; text-ref decoration rides the shell's editor transforms const commandMenuOpen = useMenuLauncher(source => source === 'command') + const [activity, setActivity] = useState(false) + useEffect(() => { setActivity(false) }, [sessionId]) const promptError = useSession(s => s.promptError) ?? null const running = useSession(s => s.running) ?? false const subagent = useSession(s => s.subagent) ?? null @@ -198,7 +200,7 @@ export const InputBar = memo(function InputBar({ // client-side size or count limit and upload as soon as they are picked. // The host enforces the same image limits at submit for callers that bypass // this composer. - const intakeFiles = useCallback((files: readonly File[]): void => { + const intakeFiles = useCallback((files: readonly File[], directories?: ReadonlySet): void => { if (subagent !== null || addFiles === undefined || files.length === 0) return const rejected = ((): string | null => { if (imageLimits !== undefined) { @@ -217,7 +219,7 @@ export const InputBar = memo(function InputBar({ return t('image.totalTooLarge', { size: imageSizeText(imageLimits.maxMessageImageBytes) }) } } - return addFiles(files) + return addFiles(files, directories) })() if (rejected !== null) showToast(rejected) }, [subagent, addFiles, attachments, imageLimits, showToast, t]) @@ -406,7 +408,7 @@ export const InputBar = memo(function InputBar({ showPlaceholder={draft === '' && attachments.length === 0 && !claimActive} />
-
+
) diff --git a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx index e90a11a163..db7eb068ba 100644 --- a/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.client.spec.tsx @@ -1,4 +1,8 @@ // @vitest-environment jsdom +import { render, cleanup } from '@testing-library/react' +import { $getRoot, $isTextNode, PASTE_COMMAND } from 'lexical' +import { projectUserText } from '@deepseek-ai/dsh-client-ui-primitives' +import { registerComposerKeymap } from '../src/client/input/editor/keymap.ts' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, onTestFinished, vi } from 'vitest' import type { CommandContribution, CommandUiContract } from '@deepseek-ai/dsh-client-ui-commands/client' @@ -399,6 +403,90 @@ describe('Conversation inject API', () => { await b.runtime.dispose() }) + it('cites shell-named files and folders as @ references and refuses folders without the shell bridge', async () => { + const browser = await bench() + const folder = new File([], 'project') + expect(browser.composerApi(ROOT).addFiles?.([folder], new Set([folder]))) + .toBe('只有桌面端支持添加文件夹,浏览器里请添加单个文件') + expect(browser.inputApi(ROOT).state.getSnapshot().attachmentIds).toEqual([]) + await browser.runtime.dispose() + + const paths = new Map([['project', '/Users/me/my project'], ['notes.md', '/Users/me/notes.md'], ['shot.png', '/Users/me/shot.png']]) + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => paths.get(file.name) ?? '' }) + try { + const desktop = await bench() + const composer = desktop.composerApi(ROOT) + const { state } = desktop.inputApi(ROOT) + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + const shot = new File([Uint8Array.of(2)], 'shot.png', { type: 'image/png' }) + const pasted = new File([Uint8Array.of(3)], 'pasted.bin', { type: 'application/octet-stream' }) + expect(composer.addFiles?.([folder, note, shot, pasted], new Set([folder]))).toBeNull() + // The folder and the file became references in the draft; the image and the pathless bytes stayed drafts. + expect(state.getSnapshot().draft).toBe('@"/Users/me/my project/" @/Users/me/notes.md ') + const drafts = composer.resolveDraftAttachments?.(state.getSnapshot().attachmentIds) ?? [] + expect(drafts.map(draft => draft.kind)).toEqual(['image', 'file']) + await vi.waitFor(() => { expect(desktop.rootUpload).toHaveBeenCalledOnce() }) + // A directory the shell cannot name is refused even with the bridge present. + const nameless = new File([], 'nameless') + expect(composer.addFiles?.([nameless], new Set([nameless]))) + .toBe('无法获取文件夹路径,请重新拖入') + await desktop.runtime.dispose() + } finally { + vi.unstubAllGlobals() + } + }) + + it('preserves selected text and file order and restores pasted directory chips from the draft', async () => { + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => `/proj/${file.name}` }) + onTestFinished(() => { vi.unstubAllGlobals(); cleanup() }) + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const composer = b.composerApi(ROOT) + const editor = composer.keyboard!.editor + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('读取') + editor.update(() => { + const text = $getRoot().getAllTextNodes()[0] + if (text === undefined || !$isTextNode(text)) throw new Error('expected text selection') + text.select(0, 2) + }, { discrete: true }) + const off = registerComposerKeymap(editor, { + arbitrate: () => 'pass', space: () => false, dismissPopup: () => {}, + canSubmit: () => true, submit: () => {}, pasteText: (text) => { composer.keyboard!.paste(text) }, + intakeFiles: (files, directories) => { expect(composer.addFiles?.(files, directories)).toBeNull() }, + }) + onTestFinished(off) + const folder = new File([], 'my project') + const files = [new File([], 'a.txt'), folder, new File([], 'b.txt')] + const event = new KeyboardEvent('paste', { cancelable: true }) + Object.defineProperty(event, 'clipboardData', { value: { + items: files.map(file => ({ + kind: 'file', getAsFile: () => file, webkitGetAsEntry: () => ({ isDirectory: file === folder }), + })), getData: () => '', + } }) + editor.update(() => { editor.dispatchCommand(PASTE_COMMAND, event) }, { discrete: true }) + expect(state.getSnapshot().draft).toBe('读取 @a.txt @"my project/" @b.txt ') + const view = render(
{projectUserText(state.getSnapshot().draft, [])}
) + expect(view.container.querySelector('[data-ref-chip="folder"]')?.textContent).toBe('my project') + expect(view.container.querySelector('[data-ref-chip="folder"]')?.getAttribute('title')).toBe('@"my project/"') + }) + + it('rejects the whole file batch before changing the draft when a later path is unrepresentable', async () => { + vi.stubGlobal('__DSH_HOST_PATHS__', { pathFor: (file: File) => `/proj/${file.name}` }) + onTestFinished(() => { vi.unstubAllGlobals() }) + const b = await bench() + onTestFinished(() => b.runtime.dispose()) + const { state, actions } = b.inputApi(ROOT) + actions.setDraft('keep this text') + for (const name of ['bad"name', 'bad\nname']) { + const files = [new File([], 'good.txt'), new File([], name)] + expect(b.composerApi(ROOT).addFiles?.(files)).toBe('路径含有无法引用的字符,请改名后再试') + expect(state.getSnapshot().draft).toBe('keep this text') + expect(state.getSnapshot().attachmentIds).toEqual([]) + expect(b.rootUpload).not.toHaveBeenCalled() + } + }) + it('fails loud for an unknown binding or an unloaded scoped service', async () => { const b = await bench() const entry = b.entryOf('conversation.composer.bar') diff --git a/packages/client/ui-conversation/tests/async-insertion.client.spec.ts b/packages/client/ui-conversation/tests/async-insertion.client.spec.ts new file mode 100644 index 0000000000..ef7cf1fcb9 --- /dev/null +++ b/packages/client/ui-conversation/tests/async-insertion.client.spec.ts @@ -0,0 +1,38 @@ +/** Late input insertions preserve references, subsequent edits, and undo ownership. */ +import { Context } from '@deepseek-ai/cordis' +import { UNDO_COMMAND } from 'lexical' +import { expect, it, vi } from 'vitest' +import { SessionInputShell } from '../src/client/input/facade.ts' + +function shell(): SessionInputShell { + return new SessionInputShell({ actx: new Context(), defaultSink: vi.fn(), commandAttachments: { + serialize: async () => [], release: () => {}, unsupportedNotice: () => '', + } }) +} + +it('inserts text beside an existing reference and undoes only that insertion', async () => { + const input = shell() + try { + input.setDraft('@ref') + input.insertReference({ source: 'file', ref: 'src/main.ts', label: 'main.ts', clipboardText: '@[main.ts](src/main.ts)' }, + { start: 0, end: 4, draftRev: input.state.getSnapshot().draftRev }) + const original = input.state.getSnapshot() + expect(input.actions.insertText(' check types', input.actions.captureInsertion())).toBe(true) + expect(input.state.getSnapshot().draft).toContain('check types') + expect(input.state.getSnapshot().occurrences).toEqual(original.occurrences) + input.editor.dispatchCommand(UNDO_COMMAND, undefined) + await vi.waitFor(() => { expect(input.state.getSnapshot().draft).toBe(original.draft) }) + } finally { input.dispose() } +}) + +it('refuses results captured before a user edit or after editor disposal', () => { + const input = shell() + input.setDraft('before') + const span = input.actions.captureInsertion() + input.setDraft('my newer text') + expect(input.actions.insertText('late voice', span)).toBe(false) + expect(input.state.getSnapshot().draft).toBe('my newer text') + const latest = input.actions.captureInsertion() + input.dispose() + expect(input.actions.insertText('late voice', latest)).toBe(false) +}) diff --git a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts index 8a919e624c..2f57b05f27 100644 --- a/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-registry.client.spec.ts @@ -72,7 +72,6 @@ function fakeSessions(ctx: Context): { sessions: ISessions; binding: SessionBind byId: {}, phase: 'ready', projectionsBySession: {}, - jobsBySession: {}, }) const reference = { sessionId: SESSION_ID, diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx index b5de7d3ade..3d1b833896 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.client.spec.tsx @@ -22,7 +22,7 @@ afterEach(() => { function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {}, + ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, })) } diff --git a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx index 9e0366952c..0e9349e51d 100644 --- a/packages/client/ui-conversation/tests/input-bar.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.client.spec.tsx @@ -19,6 +19,7 @@ import { } from '@deepseek-ai/dsh-client-test-runtime' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { SessionListState, SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' +import type { ContextPressureProjection } from '@deepseek-ai/dsh-token-meter/client' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -26,7 +27,7 @@ import type { SubmitOutcome } from '../src/client/contract/input.ts' import { SessionInputShell } from '../src/client/input/facade.ts' import { $replaceDetectSpanWithText, $selectDetectSpan } from '../src/client/input/editor/span-map.ts' import type { - ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads, + ComposerAttachment, ComposerAttachmentsOwnerProps, DraftFileUploads, InputActivityOwnerProps, } from '../src/client/contract/slots.ts' import type { DraftAttachmentId } from '../src/client/contract/input.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' @@ -92,11 +93,13 @@ interface BenchOptions { overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + activityEntry?: (owner: InputActivityOwnerProps) => React.ReactNode + contextPressure?: ContextPressureProjection footer?: React.ReactNode attachments?: readonly ComposerAttachment[] /** Upload states served for file-kind drafts (absent = every file is ready). */ fileUploads?: DraftFileUploads - addFiles?: (files: readonly File[]) => string | null + addFiles?: (files: readonly File[], directories?: ReadonlySet) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -162,6 +165,7 @@ function bench(over?: BenchOptions) { if (key === 'conversation.input.plan') return over?.planEntry ?? null if (key === 'conversation.input.permission') return over?.permissionEntry ?? null if (key === 'conversation.input.model') return over?.modelEntry ?? null + if (key === 'conversation.input.activity') return over?.activityEntry?.(owner as InputActivityOwnerProps) ?? null return null }) as never const props: InputBarProps = { @@ -175,7 +179,7 @@ function bench(over?: BenchOptions) { useResource, useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, phase: 'ready', - projectionsBySession: {}, jobsBySession: {}, + projectionsBySession: {}, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, @@ -184,7 +188,8 @@ function bench(over?: BenchOptions) { (selector ?? (v => v))(key === 'plan' ? over?.plan : key === 'goal' ? over?.goal - : key === 'imageLimits' ? over?.imageLimits : undefined)), + : key === 'imageLimits' ? over?.imageLimits + : key === 'contextPressure' ? over?.contextPressure : undefined)), useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, @@ -344,11 +349,34 @@ describe('image draft rail', () => { getData: () => '同时粘贴的文字', }, }) - expect(addFiles).toHaveBeenCalledWith([image]) + expect(addFiles).toHaveBeenCalledWith([image], undefined) // The paste lands inside the PASTE_COMMAND update; its commit is a microtask away. await vi.waitFor(() => { expect(shell.snapshot.draft).toBe('同时粘贴的文字') }) }) + it('passes known clipboard directories and keeps files whose entry metadata is unavailable', () => { + const addFiles = vi.fn(() => null) + const { textarea } = bench({ addFiles }) + const folder = new File([], 'folder with spaces') + const emptyFile = new File([], 'empty-file') + const withoutApi = new File([], 'without-api') + const withoutEntry = new File([], 'without-entry') + fireEvent.paste(textarea, { + clipboardData: { + items: [ + { kind: 'string', getAsFile: () => null }, + { kind: 'file', getAsFile: () => folder, webkitGetAsEntry: () => ({ isDirectory: true }) }, + { kind: 'file', getAsFile: () => emptyFile, webkitGetAsEntry: () => ({ isDirectory: false }) }, + { kind: 'file', getAsFile: () => withoutApi }, + { kind: 'file', getAsFile: () => withoutEntry, webkitGetAsEntry: () => null }, + { kind: 'file', getAsFile: () => null }, + ], + getData: () => '', + }, + }) + expect(addFiles).toHaveBeenCalledWith([folder, emptyFile, withoutApi, withoutEntry], new Set([folder])) + }) + it('pre-checks projected limits at intake: whole-batch refusal with product copy, none added', () => { const limits = { maxImageBytes: 1024 * 1024, @@ -386,7 +414,7 @@ describe('image draft rail', () => { const within = bench({ addFiles: vi.fn(() => null), imageLimits: limits }) const fits = png(16, 'fits.png') intake(within, [fits]) - expect(within.props.addFiles).toHaveBeenCalledWith([fits]) + expect(within.props.addFiles).toHaveBeenCalledWith([fits], undefined) expect(within.view.queryByRole('alert')).toBeNull() }) @@ -409,7 +437,7 @@ describe('image draft rail', () => { new File([new ArrayBuffer(64)], 'b.pdf', { type: 'application/pdf' }), ] act(() => { attachmentOwner(result.slotCalls).onAddFiles(files) }) - expect(addFiles).toHaveBeenCalledWith(files) + expect(addFiles).toHaveBeenCalledWith(files, undefined) expect(result.view.getByRole('alert').textContent).toContain('仅支持 PNG、JPG、WebP、GIF 格式的图片') }) @@ -428,6 +456,17 @@ describe('image draft rail', () => { expect(attachmentOwner(result.slotCalls).dropLimits).toEqual({ count: 20, size: '5MB' }) }) + it('forwards dropped directories to addFiles and announces its refusal', () => { + const addFiles = vi.fn((_files: readonly File[], directories?: ReadonlySet) => + directories !== undefined && directories.size > 0 ? '只有桌面端支持添加文件夹,浏览器里请添加单个文件' : null) + const result = bench({ addFiles }) + const folder = new File([], 'project') + const note = new File([Uint8Array.of(1)], 'notes.md', { type: 'text/markdown' }) + act(() => { attachmentOwner(result.slotCalls).onAddFiles([folder, note], new Set([folder])) }) + expect(addFiles).toHaveBeenCalledWith([folder, note], new Set([folder])) + expect(result.view.getByRole('alert').textContent).toContain('只有桌面端支持添加文件夹') + }) + it('announces server attachment rejections as product copy, other codes as developer text', () => { const attachmentError = (reason: string): SessionSnapshot['promptError'] => ({ op: 'send', @@ -1610,7 +1649,7 @@ describe('command launcher chrome and control seats', () => { expect([...new Set(slotCalls.map(c => c.key))]).toEqual([ 'conversation.input.overlay', 'conversation.input.attachments', 'conversation.input.permission', 'conversation.input.plan', 'conversation.input.left', - 'conversation.input.right', 'conversation.input.model', + 'conversation.input.right', 'conversation.input.model', 'conversation.input.activity', 'conversation.composer.dock', ]) expect(view.queryByLabelText('Plan mode')).toBeNull() @@ -1673,3 +1712,42 @@ describe('command launcher chrome and control seats', () => { expect((live.view.getByLabelText('添加文件或调用指令') as HTMLButtonElement).disabled).toBe(false) }) }) + +it('lets a toolbar activity replace accessories without replacing the draft editor or send action', () => { + const { view } = bench({ draft: 'keep this draft', modelEntry: , + activityEntry: owner => <> + + + , + }) + const editor = view.getByRole('textbox') + fireEvent.click(view.getByRole('button', { name: 'expand activity' })) + expect(view.queryByRole('button', { name: 'model choice' })).toBeNull() + expect(view.getByRole('textbox')).toBe(editor) + expect(editor.textContent).toBe('keep this draft') + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'close activity' })) + expect(view.getByRole('button', { name: 'model choice' })).toBeTruthy() +}) + +it('places context usage below the composer and hides it until the activity closes', () => { + const { view } = bench({ draft: 'draft', contextPressure: { pressureTokens: 32_000, contextWindow: 128_000 }, + activityEntry: owner => <> + + + , + }) + const meter = view.getByRole('button', { name: '上下文已用 25%' }) + const microphone = view.getByRole('button', { name: 'microphone' }) + expect(microphone.compareDocumentPosition(meter) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy() + fireEvent.click(meter) + expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() + fireEvent.click(microphone) + expect(view.queryByRole('dialog', { name: '上下文已用' })).toBeNull() + expect(view.queryByRole('button', { name: '上下文已用 25%' })).toBeNull() + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: 'close activity' })) + fireEvent.click(view.getByRole('button', { name: '上下文已用 25%' })) + expect(view.getByRole('dialog', { name: '上下文已用' })).toBeTruthy() + expect(view.getByRole('button', { name: '发送消息' })).toBeTruthy() +}) diff --git a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx index 15d87a0ca7..1dcbd65f9c 100644 --- a/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.client.spec.tsx @@ -54,7 +54,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined, + projectionsBySession: {}, currentAddress: undefined, })), useSessionStatus: bindSnapshotSelector( createSnapshotStore(new Map()), @@ -283,6 +283,11 @@ describe('matrix row: claimed with attachments', () => { act(() => { shell.addAttachments([img]) }) fireEvent.keyDown(textarea, { key: 'Enter' }) expect(shell.snapshot.phase).toBe('submitting') + const draft = shell.snapshot.draft + expect(shell.addFiles([{ + source: 'reference', ref: '@note.txt', label: 'note.txt', clipboardText: '@note.txt', + }], ['new-file' as DraftAttachmentId])).toBe(false) + expect(shell.snapshot.draft).toBe(draft) let removed = true act(() => { removed = shell.removeAttachment(img) }) expect(removed).toBe(false) diff --git a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx index 347b4d05c1..e119a8104d 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.client.spec.tsx @@ -148,7 +148,7 @@ async function scopedBench(register?: (inputTriggers: InputTriggerService) => vo useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - projectionsBySession: {}, jobsBySession: {}, currentAddress: undefined, + projectionsBySession: {}, currentAddress: undefined, })), useSessionStatus: bindSnapshotSelector( createSnapshotStore(new Map()), diff --git a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx index 17aadfb056..911e331bc1 100644 --- a/packages/client/ui-conversation/tests/skeleton.client.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.client.spec.tsx @@ -164,7 +164,7 @@ function mount( ...listed && options.nestedSubagent === true && { [parent]: parentRow }, ...listed && { [SID]: childRow }, }, - phase: 'ready', projectionsBySession: {}, jobsBySession: {}, + phase: 'ready', projectionsBySession: {}, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 1dbe8b1ce5..a4df996a41 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -92,6 +92,9 @@ { "path": "../../util/values" }, + { + "path": "../../context/file-reference" + }, { "path": "../../util/workspace-path" } diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index 7ea790985c..c3c62898a1 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -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-deliverables/README.md -README.md: 71f3545b8fb3fed3ecd678258e6c50bbfa4b580c -README.zh.md: 3e59ed2b7d4ac7e9d64b7d1f2f3c477e3ca1c50e +README.md: ce006783310c257957bc8cfddcfd18226972d18f +README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 71f3545b8f..ce00678331 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -30,9 +30,9 @@ Mount this plugin alongside `ui-conversation` and the Host [workspace-changes](. ### Explicit deliveries -The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. The closing turn shows one delivery as a full-width card and multiple deliveries in a two-column grid with 10px gaps. A list longer than four files starts collapsed and provides a control that reveals or hides the complete list. Each 60px-high card uses 8px vertical and 10px horizontal inset spacing, a 20px shared `FileTypeIcon` in a 40px frame, 13px filename text, 10px secondary text, and a 12px Open action. It shows the basename and description, or the file type when no description exists; a trailing parenthesized suffix in the description is omitted, and hovering the card replaces that line with the Sidebar-preview action. Clicking the card or the left side of its split Open control previews the file in the right Sidebar. The chevron opens the standard menu for the Host default application plus Show in Finder on macOS, Show in File Explorer on Windows and WSL, or Open containing folder through the default Linux file manager. Matching inline-code references preview the same source files in the right Sidebar; native opening requires an explicit card-menu action. Repeated declaration of a path selects its latest description before the closing reply. +The Web `standard`, `ptc`, and `cordis` presets expose `present` for final files accessible through the Session filesystem, including files created through Bash. Call it with `files: [{ path, description? }]` after creating the files. The [present tool](../../deliverables/tool-present/README.md) owns file-count limits and Session declarations. A single delivery fills the row; multiple deliveries form a two-column grid, collapsed after four files. Cards show the basename, description or file type, and a shared native opening control. Clicking the card previews the file in the right Sidebar. The control opens the default application and lists associated applications, with file reveal last. Matching inline-code references also open Sidebar previews. Duplicate declarations use the last description before the closing reply. -The `present` tool row retains the delivery-document glyph while showing running, delivered, failed, or interrupted status; expanding a settled row reveals its recorded result. The collapsible card grid retains every delivered file. Both menu actions share pending state and show progress, acknowledgement, or an action-specific retryable error. Desktop information is read when delivery cards appear and invalidated on connection replacement; responses from a replaced connection cannot publish metadata. Selecting a native menu action returns keyboard focus to the available Sidebar Open button. Pending actions close the menu until another explicit gesture. A missing desktop disables the Open menu; a failed desktop-information read offers Retry. It requires a desktop and a suitable default application on the serving Host; a remote browser does not open applications on its own device. +The `present` tool row retains its document icon and recorded result across progress, success, failure, and interruption. Native actions share pending state and publish progress or retryable errors on the card. Successful open or reveal feedback stays visible for five seconds, fades over 200ms, then restores the file description; failures remain visible until another attempt. Desktop metadata is loaded when cards mount and invalidated on connection replacement. Without a Host desktop, cards retain Sidebar preview but omit native controls; failed metadata reads offer Retry. Delivery cards and change review expose `deliverables.file.actions` and `deliverables.review.file.actions`, supplied by [ui-open-in-app](../ui-open-in-app/README.md). Association GET requests and native POST actions both validate the recorded file coordinates and Session filesystem mapping before using the Host desktop. ### The changed-files card diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 3e59ed2b7d..82239ba169 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -30,9 +30,9 @@ kind: "package-reference" ### 显式交付 -Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交付会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建文件后,以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。收尾轮次把单个交付显示为横向占满内容区的卡片,把多个交付显示为间距 10px 的双列网格。文件超过四个时,列表默认收起,并提供显示或隐藏完整列表的控件。每张卡片高 60px,上下内边距为 8px、左右为 10px;40px 图标框内使用 20px 的共享 `FileTypeIcon`,文件名为 13px、次要文本为 10px,“打开”操作为 12px。卡片显示 basename 与说明;没有说明时显示文件类型,说明末尾的括号后缀会被省略,悬停卡片时该行切换为侧栏预览提示。点击卡片或分段“打开”控件的左侧会在右侧 Sidebar 中预览文件;右侧箭头打开标准菜单,其中提供 Host 默认应用,以及 macOS 上的“在 Finder 中显示”、Windows 和 WSL 上的“在文件资源管理器中显示”或 Linux 默认文件管理器的“打开所在文件夹”。匹配的行内代码引用在右侧 Sidebar 中预览相同源文件;原生打开需要显式选择卡片菜单中的操作。同一路径重复声明时,选择收尾回复之前最近一次的说明。 +Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明会话文件系统可访问的最终文件,包括通过 Bash 创建的文件。创建后以 `files: [{ path, description? }]` 调用。[present 工具](../../deliverables/tool-present/README.zh.md)拥有文件数量限制和会话声明。单个交付占满一行,多个交付使用双列网格,超过四个时默认收起。卡片显示文件名、说明或文件类型,以及共享的原生打开控件。点击卡片会在右侧 Sidebar 预览文件。控件打开默认应用,并列出关联应用,文件定位始终放在最后。匹配的行内代码引用也打开 Sidebar 预览。重复声明使用收尾回复之前最近一次的说明。 -`present` 工具行在正在交付、已交付、失败或中断状态下都保留交付文档图标;展开已结束的调用可查看其记录的结果。可折叠卡片网格保留全部交付文件。菜单中的两个操作共享等待状态,并显示进度、成功确认或各自可重试的错误。交付卡片出现时读取桌面信息,连接更换时清除缓存,旧连接的响应不能更新元数据。选择原生菜单操作后,键盘焦点回到仍可用的侧边栏“打开”按钮。等待操作完成时关闭菜单,用户再次点击才会打开。Host 没有桌面时禁用“打开”菜单;桌面信息读取失败时提供“重试”。服务 Host 必须具备桌面和合适的默认应用;远程浏览器不会打开其所在设备上的应用。 +`present` 工具行在执行、成功、失败和中断状态下保留文档图标及记录的结果。原生操作共享等待状态,并在卡片显示进度或可重试错误。打开或定位成功的反馈显示五秒后,在 200ms 内淡出并恢复文件说明;失败信息保留到下次尝试。卡片挂载时读取桌面信息,连接更换时清除缓存。Host 没有桌面时,卡片保留 Sidebar 预览并隐藏原生控件;元数据读取失败时提供重试。交付卡片和变更对比页通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 使用 [ui-open-in-app](../ui-open-in-app/README.zh.md) 提供的控件。关联应用 GET 查询与原生 POST 操作都会先校验记录中的文件坐标及会话文件系统映射,再调用 Host 桌面。 ### 改动文件卡片 diff --git a/packages/client/ui-deliverables/src/client/Deliverables.module.css b/packages/client/ui-deliverables/src/client/Deliverables.module.css index c772e6d01c..c59f619a02 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.module.css +++ b/packages/client/ui-deliverables/src/client/Deliverables.module.css @@ -18,18 +18,14 @@ .description { overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; font-weight: 400; line-height: 16px; text-overflow: ellipsis; white-space: nowrap; } .description[data-error='true'] { color: var(--dsw-alias-state-error-primary); } .previewHint { display: none; } -.file:hover .secondaryText { display: none; } -.file:hover .previewHint { display: inline; } -.split { display: inline-flex; flex: none; align-items: stretch; box-sizing: border-box; height: 28px; overflow: hidden; pointer-events: auto; border: 0.5px solid var(--dsw-alias-border-l3); border-radius: 10px; background: var(--dsw-alias-button-floating-fill); } -.menuAnchor { align-self: stretch; } -.open, .chevron { display: inline-flex; align-items: center; justify-content: center; border: 0; background: none; color: var(--dsw-alias-label-primary); cursor: pointer; font-family: var(--dsw-font-family); } -.open { padding: 4px 8px; font-size: 12px; line-height: 18px; } -.chevron { padding: 4px 5px; border-left: 0.5px solid var(--dsw-alias-border-l3); color: var(--dsw-alias-label-secondary); } -.open:hover, .open:focus-visible, .chevron:hover:not(:disabled), .chevron:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } -.chevron:disabled { color: var(--dsw-alias-label-dimmed); cursor: not-allowed; } -.menuActionIcon { display: block; width: 16px; height: 16px; } +.file:hover .description:not([role='status']) .secondaryText { display: none; } +.file:hover .description:not([role='status']) .previewHint { display: inline; } .toggle { align-self: center; display: inline-flex; align-items: center; gap: 4px; min-width: 0; padding: 1px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; font: inherit; font-size: 12px; line-height: 18px; } .toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } .toggle svg { flex: none; width: 14px; height: 14px; } @container (max-width: 620px) { .presented { grid-template-columns: minmax(0, 1fr); } } -@media (pointer: coarse) { .split { min-height: 44px; } .open, .chevron { min-width: 44px; } } +.actions { display: inline-flex; flex: none; pointer-events: auto; } + +.secondaryText[data-success] { animation-name: success-fade; animation-timing-function: ease-out; animation-fill-mode: forwards; } +@keyframes success-fade { to { opacity: 0; } } +@media (prefers-reduced-motion: reduce) { .secondaryText[data-success] { animation-name: none; } } diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index ed7c034941..3fae39bd01 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' import type { ChangesSummaryStore } from './changes-summary.ts' @@ -50,24 +50,24 @@ export function selectDeliverables(owner: TurnTailOwnerProps): DeliverablesMatch * @param props - closing Turn, file actions, and localized copy. * @returns file rows, or null when the Turn declares none. */ -export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale & InjectFace) { +export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail'> & PropsLocale & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { const matched = selectDeliverables(props) return matched === null ? null : } /** * Render the changed-files card, once the Host has served the announced - * summary and it lists a file, and default-application buttons for declared + * summary and it lists a file, and shared native opening controls for declared * files. A summary the Host no longer serves leaves no card. * @param props - matched announcement and files, workspace opener, and localized copy. * @returns the closing turn's file rows. */ export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, - useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, + useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, }: Pick & { matched: DeliverablesMatch -} & PropsLocale & Pick & Pick & InjectFace) { +} & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { const [expanded, setExpanded] = useState(false) const showCodeDiff = useShowCodeDiff(value => value) const cwd = useSessions(state => state.byId[sessionId]?.cwd) @@ -105,7 +105,13 @@ export function Deliverables({ phase={states[presentedFileUrl(sessionId, file.seq, file.index)]} host={host === 'error' ? null : host} t={t} onPreview={() => { openFile(file.path) }} - onAction={(action) => { void openPresented(sessionId, file.seq, file.index, action) }} />)} + actions={renderSlot('deliverables.file.actions', { + actionUrl: presentedFileUrl(sessionId, file.seq, file.index), + available: host !== null && host !== 'error' && host.available, + pending: states[presentedFileUrl(sessionId, file.seq, file.index)] === 'opening' + || states[presentedFileUrl(sessionId, file.seq, file.index)] === 'revealing', + onAction: (action, application) => openPresented(sessionId, file.seq, file.index, action, application), + })} />)}
{collapsible && - { setMenuOpen(false) }} - anchor={} - items={[ - { id: 'open', icon: , - label: t('presented.defaultApp') }, - { id: 'reveal', icon: , - label: t(`presented.${reveal}`) }, - ]} - onSelect={(id) => { act(id === 'reveal' ? 'reveal' : 'open') }} /> - +
{actions}
} diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 01ac90f1fa..362f88ec9d 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -7,11 +7,11 @@ import { useEffect, useMemo, useRef, useState } from 'react' import type { ReactNode, UIEvent } from 'react' import { Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, - IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, Tooltip, + IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' -import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' @@ -45,7 +45,7 @@ export interface ReviewInjected { /** The body's composed props: the tab it draws, its store, its injected face, and its copy. */ export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> - & InjectFace & PropsLocale + & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.review.file.actions'> /** One drawn line of a hunk with its line numbers on each side. */ export interface DiffRow { @@ -189,7 +189,7 @@ function Counts({ file, t }: { file: WorkspaceChangedFile } & PropsLocale { tab.actions.openResource(fileAddressFor(sessionId, cwd, file.path)) }}> } - {file !== undefined && native && - - } + {file !== undefined && renderSlot('deliverables.review.file.actions', { + actionUrl: changedFileUrl(sessionId, seq, index), available: native, + pending: phase === 'opening' || phase === 'revealing', + onAction: (action, application) => openChanged(sessionId, seq, index, action, application), + })} {summaryState === 'loading' &&

{t('diff.loading')}

} diff --git a/packages/client/ui-deliverables/src/client/file-actions.ts b/packages/client/ui-deliverables/src/client/file-actions.ts new file mode 100644 index 0000000000..1326f80b76 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/file-actions.ts @@ -0,0 +1,27 @@ +/** Native file-action contribution shared by delivery cards and change review. */ +import type { PresentedOpenFailure } from './present-open.ts' +import type { PresentedAction } from '../presented.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + /** Open one file through its authorized Session event coordinates. */ + 'deliverables.file.actions': { + kind: 'list' + scope: 'session' + owner: { + /** Authenticated document-relative action route carrying Session event coordinates. */ + readonly actionUrl: string + readonly available: boolean + readonly pending: boolean + /** Execute the selected native action and publish status on the owning file surface. */ + readonly onAction: (action: PresentedAction, application?: string) => Promise + } + } + /** The same file actions owned by a changed-file review tab. */ + 'deliverables.review.file.actions': { + kind: 'list' + scope: 'session' + owner: SlotMap['deliverables.file.actions']['owner'] + } + } +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index e61596ea4b..f000547756 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -8,6 +8,7 @@ * cap, and copy — so composing this plugin out of cordis.yml removes every * surface; the owning view renders an empty list and inert prose at zero cost. */ +import './file-actions.ts' import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-connection/client' @@ -63,13 +64,14 @@ export function apply(ctx: ClientContext): void { name: 'conversation.chat.turnTail', id: '@deepseek-ai/dsh-client-ui-deliverables', locale: NS, + children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, inject: (): DeliverablesInjected => ({ hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, showCodeDiff: ctx.settingsScope.developerTools.enabled }, reloadPresentedHost: () => opener.loadHost(), loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), - openPresented: (sessionId, seq, index, action) => opener.open(sessionId, seq, index, action), - openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), + openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application), + openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application), openChangesReview: (coordinates, index) => { ctx.sidebarRight.openResource(changesReviewAddress(coordinates), { params: { index } }) }, @@ -84,12 +86,13 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register( { name: 'sidebar.right.pane.tab', key: CHANGES_REVIEW_ID, locale: NS, store: createReviewStore(), + children: { 'deliverables.review.file.actions': { kind: 'list', scope: 'session' } }, inject: (): ReviewInjected => ({ hooks: { changesSummary: summaries.state, changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host }, loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), reloadPresentedHost: () => opener.loadHost(), - openChanged: (sessionId, seq, index) => opener.openChanged(sessionId, seq, index), + openChanged: (sessionId, seq, index, action, application) => opener.openChanged(sessionId, seq, index, action, application), }), }, ReviewTab, diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts index 7eacc52c34..69e976fa06 100644 --- a/packages/client/ui-deliverables/src/client/locales.ts +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -15,12 +15,6 @@ export const zh = { 'presented.unavailable': '此主机没有可用的桌面,无法使用外部程序打开文件或文件夹;文件仍可在侧边栏预览', 'presented.retry': '重试', 'presented.hostError': '无法读取主机桌面信息', - 'presented.directory': '打开所在文件夹', - 'presented.explorer': '在文件资源管理器中显示', - 'presented.finder': '在 Finder 中显示', - 'presented.defaultApp': '用默认应用打开', - 'presented.more': '{name} 的更多文件操作', - 'presented.action': '打开', 'presented.preview': '在侧边栏预览', 'presented.previewButton': '在侧边栏打开 {name}', 'presented.previewCard': '在侧边栏预览 {name}', @@ -29,7 +23,7 @@ export const zh = { 'presented.collapse': '收起', 'presented.collapseAria': '收起交付文件列表', 'presented.opening': '正在打开…', - 'presented.opened': '已在默认程序中打开', + 'presented.opened': '已请求打开', 'presented.error': '打开失败,点击重试', 'presented.file': '文件', 'row.title': '交付文件', @@ -59,7 +53,6 @@ export const zh = { 'review.wrapAria': '自动换行', 'review.openFile': '在侧边栏打开整个文件', 'review.openFileAria': '在侧边栏打开 {name}', - 'diff.openNativeAria': '用默认应用打开 {name}', 'diff.loading': '正在读取改动…', 'diff.missing': '这轮改动的内容已不可用', 'diff.error': '无法读取改动', @@ -70,8 +63,6 @@ export const zh = { 'diff.unchanged': '两侧内容相同', 'diff.coarse': '逐行对比超时,按整个文件替换显示', 'diff.truncated': '只显示前 {count} 行', - 'diff.openNative': '用默认应用打开', - 'diff.openNativeError': '打开失败,点击重试', } /** English dictionary (same key set). */ @@ -86,12 +77,6 @@ export const en: Record = { 'presented.unavailable': 'This Host has no desktop available to open files or folders in external apps. Files can still be previewed in the sidebar.', 'presented.retry': 'Retry', 'presented.hostError': 'Could not read the Host desktop information', - 'presented.directory': 'Open containing folder', - 'presented.explorer': 'Show in File Explorer', - 'presented.finder': 'Show in Finder', - 'presented.defaultApp': 'Open in default app', - 'presented.more': 'More file actions for {name}', - 'presented.action': 'Open', 'presented.preview': 'Preview in sidebar', 'presented.previewButton': 'Open {name} in sidebar', 'presented.previewCard': 'Preview {name} in sidebar', @@ -100,7 +85,7 @@ export const en: Record = { 'presented.collapse': 'Collapse', 'presented.collapseAria': 'Collapse delivered files', 'presented.opening': 'Opening…', - 'presented.opened': 'Opened in default app', + 'presented.opened': 'Open requested', 'presented.error': 'Could not open. Click to retry.', 'presented.file': 'File', 'row.title': 'Present files', @@ -130,7 +115,6 @@ export const en: Record = { 'review.wrapAria': 'Line wrap', 'review.openFile': 'Open the whole file in the sidebar', 'review.openFileAria': 'Open {name} in sidebar', - 'diff.openNativeAria': 'Open {name} in default app', 'diff.loading': 'Reading changes…', 'diff.missing': 'The contents of this turn’s changes are no longer available', 'diff.error': 'Could not read the changes', @@ -141,8 +125,6 @@ export const en: Record = { 'diff.unchanged': 'Both sides hold the same lines', 'diff.coarse': 'Line comparison timed out; shown as a whole-file replacement', 'diff.truncated': 'Showing the first {count} lines', - 'diff.openNative': 'Open in default app', - 'diff.openNativeError': 'Could not open. Click to retry.', } /** Union of this namespace's dictionary keys. */ diff --git a/packages/client/ui-deliverables/src/client/present-open.ts b/packages/client/ui-deliverables/src/client/present-open.ts index d724816d19..c116de3a44 100644 --- a/packages/client/ui-deliverables/src/client/present-open.ts +++ b/packages/client/ui-deliverables/src/client/present-open.ts @@ -4,7 +4,15 @@ import type { SessionId } from '@deepseek-ai/dsh-session/types' import { changedFileUrl } from '../changes.ts' import { presentedFileUrl, PRESENT_HOST_ROUTE, isPresentedHost, type PresentedAction, type PresentedHost } from '../presented.ts' -/** State of the latest explicit open gesture for one saved file. */ +/** Success feedback remains fully visible for five seconds before fading. */ +export const PRESENTED_SUCCESS_HOLD_MS = 5000 +/** Fade interval shared by the card animation and status expiry. */ +export const PRESENTED_SUCCESS_FADE_MS = 200 + +/** Failure feedback for the shared native opening control. */ +export type PresentedOpenFailure = 'openError' | 'revealError' | null + +/** State published on the owning file card. */ export type PresentedOpenPhase = 'opening' | 'opened' | 'revealing' | 'revealed' | 'error' | 'revealError' | 'nativeUnavailable' /** One browser plugin's file-open requests, cancelled when that plugin is disposed. */ @@ -13,10 +21,11 @@ export class PresentedOpenController { readonly state = createSnapshotStore>({}) /** Native destination metadata, or a retryable read failure. */ readonly host = createSnapshotStore(null) + private readonly expiry = new Map>() private loading: Promise | undefined private metadata = new AbortController() private readonly lifetime = new AbortController() - private readonly pending = new Set>() + private readonly pending = new Set>() /** * Open a declared file once while a request for the same coordinates is pending. @@ -25,10 +34,13 @@ export class PresentedOpenController { * @param seq - durable delivery event sequence. * @param index - original file index within that event. * @param action - default application open or file-manager reveal. - * @returns after the Host acknowledges opening or the error state is published. + * @param application - registered handler identifier for an explicit application choice. + * @returns null after a successful handoff, or the failure to announce after publishing card status. */ - open(sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open'): Promise { - return this.openUrl(presentedFileUrl(sessionId, seq, index), action) + open( + sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string, + ): Promise { + return this.openUrl(presentedFileUrl(sessionId, seq, index), action, application) } /** @@ -36,20 +48,25 @@ export class PresentedOpenController { * @param sessionId - viewed Session. * @param seq - durable workspace/changes event sequence. * @param index - original file index within that event. - * @returns after the Host acknowledges opening or the error state is published. + * @param action - application open or file-manager reveal. + * @param application - registered handler identifier for an explicit application choice. + * @returns null after a successful handoff, or the failure to announce after publishing card status. */ - openChanged(sessionId: SessionId, seq: number, index: number): Promise { - return this.openUrl(changedFileUrl(sessionId, seq, index), 'open') + openChanged( + sessionId: SessionId, seq: number, index: number, action: PresentedAction = 'open', application?: string, + ): Promise { + return this.openUrl(changedFileUrl(sessionId, seq, index), action, application) } - private async openUrl(url: string, action: PresentedAction): Promise { + private async openUrl(url: string, action: PresentedAction, application?: string): Promise { const phase = this.state.getSnapshot()[url] - if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return + if (this.lifetime.signal.aborted || phase === 'opening' || phase === 'revealing') return null + this.clearExpiry(url) this.state.update((state) => { state[url] = action === 'open' ? 'opening' : 'revealing' }) - const task = this.request(url, action) + const task = this.request(url, action, application) this.pending.add(task) try { - await task + return await task } finally { this.pending.delete(task) } @@ -100,19 +117,36 @@ export class PresentedOpenController { /** Cancel outstanding requests and wait until no request can publish state. */ async dispose(): Promise { this.lifetime.abort() + for (const url of this.expiry.keys()) this.clearExpiry(url) await Promise.all(this.pending) } - private async request(url: string, action: PresentedAction): Promise { + private clearExpiry(url: string): void { + clearTimeout(this.expiry.get(url)) + this.expiry.delete(url) + } + + private async request(url: string, action: PresentedAction, application?: string): Promise { const failure = action === 'open' ? 'error' : 'revealError' let phase: PresentedOpenPhase = action === 'open' ? 'opened' : 'revealed' try { - const response = await fetch(action === 'open' ? url : `${url}&action=reveal`, { method: 'POST', signal: this.lifetime.signal }) + const target = action === 'reveal' ? `${url}&action=reveal` + : application === undefined ? url : `${url}&application=${encodeURIComponent(application)}` + const response = await fetch(target, { method: 'POST', signal: this.lifetime.signal }) if (!response.ok) phase = response.status === 422 ? 'nativeUnavailable' : failure } catch { // Transport failures share the retryable card state with Host open failures. phase = failure } - if (!this.lifetime.signal.aborted) this.state.update((state) => { state[url] = phase }) + if (!this.lifetime.signal.aborted) { + if (phase === 'opened' || phase === 'revealed') { + this.expiry.set(url, setTimeout(() => { + this.expiry.delete(url) + this.state.update((state) => { Reflect.deleteProperty(state, url) }) + }, PRESENTED_SUCCESS_HOLD_MS + PRESENTED_SUCCESS_FADE_MS)) + } + this.state.update((state) => { state[url] = phase }) + } + return phase === 'opened' || phase === 'revealed' ? null : action === 'reveal' ? 'revealError' : 'openError' } } diff --git a/packages/client/ui-deliverables/src/present-open.ts b/packages/client/ui-deliverables/src/present-open.ts index 56b88f0651..6023625644 100644 --- a/packages/client/ui-deliverables/src/present-open.ts +++ b/packages/client/ui-deliverables/src/present-open.ts @@ -34,12 +34,12 @@ export function registerPresentOpen(ctx: Context): void { await Promise.allSettled(pending) }) const routes = [ - [PRESENT_OPEN_PATH, 'POST', handlePresentOpen], [CHANGES_OPEN_PATH, 'POST', handleChangesOpen], [CHANGES_DIFF_PATH, 'GET', handleChangesDiff], + [PRESENT_OPEN_PATH, ['GET', 'POST'], handlePresentOpen], [CHANGES_OPEN_PATH, ['GET', 'POST'], handleChangesOpen], [CHANGES_DIFF_PATH, ['GET'], handleChangesDiff], ] as const - for (const [path, method, handler] of routes) { + for (const [path, methods, handler] of routes) { ctx.connection.fetch.register({ path, - methods: [method], + methods: [...methods], requestBody: 'buffered', fetch: (request) => { const task = handler(ctx, new Request(request, { @@ -91,7 +91,13 @@ async function openVerified(ctx: Context, request: Request, path: string, action return new Response('Path has no verified Host path.', { status: 422 }) } request.signal.throwIfAborted() - await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } : {}) }, request.signal) + if (request.method === 'GET') { + return Response.json(await ctx.sessionController.workspacePathApplications({ path }, request.signal), + { headers: { 'cache-control': 'no-store' } }) + } + const application = new URL(request.url).searchParams.get('application') + await ctx.sessionController.openWorkspacePath({ path, ...(action === 'reveal' ? { action } + : application === null ? {} : { application }) }, request.signal) return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } }) } @@ -159,6 +165,8 @@ async function handleChangesDiff(ctx: Context, request: Request): Promise { + const action = new URL(request.url).searchParams.get('action') ?? 'open' + if (action !== 'open' && action !== 'reveal') return new Response('Invalid file action.', { status: 400 }) const coordinates = changedFileCoordinates(request) if (coordinates instanceof Response) return coordinates const { id, seq, index } = coordinates @@ -171,7 +179,7 @@ async function handleChangesOpen(ctx: Context, request: Request): Promise ({ opened: true as const })) - ctx.provide('sessionController', { openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }]) + ctx.provide('sessionController', { workspacePathApplications: applications, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) await ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy', 'workspaceChanges'], @@ -71,7 +72,7 @@ async function fixture() { const open = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_OPEN_PATH}${query}`, { method: 'POST' })) const read = (query = '?sessionId=owner&seq=9') => handler.fetch(new Request(`http://localhost${CHANGED_FILES_PATH}${query}`)) const compare = (query = '?sessionId=owner&seq=9&index=0') => handler.fetch(new Request(`http://localhost${CHANGES_DIFF_PATH}${query}`)) - return { root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } + return { handler, applications, root, cwd, ctx, data, readEvent, open, read, compare, comparison, diff, opener, outside, summary } } describe('change summary route', () => { @@ -199,3 +200,26 @@ describe('changed files native open route', () => { expect(isChangesEvent(null)).toBe(false) }) }) + + +it('queries handlers only after file authorization and forwards explicit application choices', async () => { + const { handler, applications, opener, open, ctx } = await fixture() + const url = 'http://localhost/api/changes.open?sessionId=owner&seq=9&index=0' + const response = await handler.fetch(new Request(url)) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) + expect(opener).not.toHaveBeenCalled() + expect((await open('?sessionId=owner&seq=9&index=0&application=player')).status).toBe(204) + expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await handler.fetch(new Request(url))).status).toBe(422) + expect(applications).toHaveBeenCalledOnce() +}) + + +it('rejects an unsupported changed-file action before invoking the desktop', async () => { + const { open, opener } = await fixture() + expect((await open('?sessionId=owner&seq=9&index=0&action=remove')).status).toBe(400) + expect(opener).not.toHaveBeenCalled() +}) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index c6cb256373..831c32a74d 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -6,6 +6,7 @@ * registrations' fiber-teardown removal (HMR safety) against the real * SlotRegistry. */ +import { renderFileActions } from './file-actions.tsx' import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render, within } from '@testing-library/react' @@ -39,8 +40,10 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) { controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) - const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {}, jobsBySession: {} } + const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} } return { + SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}, + renderSlot: renderFileActions, useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), @@ -494,7 +497,7 @@ describe('ChangedFiles card', () => { controller = new PresentedOpenController(), locale = en, matched = { changes, presented: [] as never[] }, summaries = servedStore(), ) { const props = openProps(controller, summaries) - props.openChanged.mockResolvedValue(undefined) + props.openChanged.mockResolvedValue(null) const openFile = vi.fn<(path: string) => void>() const view = render() return { props, openFile, view } @@ -845,7 +848,7 @@ describe('presented files', () => { const owner = tailOwner(deliverablesOf(value), 3, preview) const matched = selectDeliverables(owner)! const props = openProps() - props.openPresented.mockResolvedValue(undefined) + props.openPresented.mockResolvedValue(null) const view = render() expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) const expand = view.getByRole('button', { name: 'Show all 8 delivered files' }) @@ -855,12 +858,10 @@ describe('presented files', () => { expect(view.getByRole('button', { name: 'Collapse delivered files' }).getAttribute('aria-expanded')).toBe('true') expect(view.queryByRole('link')).toBeNull() fireEvent.click(view.getByRole('button', { name: 'Preview report-0.docx in sidebar' })) - fireEvent.click(view.getByRole('button', { name: 'Open report-0.docx in sidebar' })) - expect(preview).toHaveBeenCalledTimes(2) + expect(preview).toHaveBeenCalledTimes(1) expect(preview).toHaveBeenLastCalledWith('report-0.docx') - fireEvent.click(view.getByRole('button', { name: 'More file actions for report-0.docx' })) - fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) - expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open') + fireEvent.click(view.getAllByRole('button', { name: 'Native file action' })[0]!) + expect(props.openPresented).toHaveBeenCalledWith('child-session', 2, 0, 'open', undefined) fireEvent.click(view.getByRole('button', { name: 'Collapse delivered files' })) expect(view.container.querySelectorAll('[data-presented-file]')).toHaveLength(4) expect(view.container.querySelector('[data-changed-files]')).toBeNull() @@ -934,7 +935,7 @@ it.each(['opening', 'opened', 'error'] as const)('shows the %s state and permits { path: 'report.txt', seq: 2, index: 0 }, ] }} openFile={() => {}} sessionId={SessionId('session')} t={makeTranslate(en)} />) expect(view.getByText(en[`presented.${phase}`])).toBeTruthy() - expect((view.getByRole('button', { name: 'More file actions for report.txt' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') + expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(phase === 'opening') }) diff --git a/packages/client/ui-deliverables/tests/file-actions.tsx b/packages/client/ui-deliverables/tests/file-actions.tsx new file mode 100644 index 0000000000..d1904bea6a --- /dev/null +++ b/packages/client/ui-deliverables/tests/file-actions.tsx @@ -0,0 +1,10 @@ +/** Capture delivery action ownership without duplicating the opening-control implementation. */ +import type { PropsRenderSlots, SlotMap } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '../src/client/index.ts' + +/** Slot renderer exposing the owning route and gesture for component tests. */ +export const renderFileActions: PropsRenderSlots<'deliverables.file.actions' | 'deliverables.review.file.actions'>['renderSlot'] = (_name: string, input: object) => { + const props = input as SlotMap['deliverables.file.actions']['owner'] + return props.available ? : null +} diff --git a/packages/client/ui-deliverables/tests/present-open.client.spec.ts b/packages/client/ui-deliverables/tests/present-open.client.spec.ts index 35fd278616..f6910df3f9 100644 --- a/packages/client/ui-deliverables/tests/present-open.client.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.client.spec.ts @@ -3,7 +3,7 @@ import { afterEach, expect, it, vi } from 'vitest' import { SessionId } from '@deepseek-ai/dsh-session/types' import { PresentedOpenController } from '../src/client/present-open.ts' -afterEach(() => { vi.unstubAllGlobals() }) +afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() }) const id = SessionId('fork') const url = 'api/present.open?sessionId=fork&seq=2&index=1' @@ -174,3 +174,68 @@ it.each(['open', 'reveal'] as const)('reports an unavailable Host path for %s wh expect(controller.state.getSnapshot()[url]).toBe('nativeUnavailable') await controller.dispose() }) + + +it('encodes an explicit application identifier without changing the file coordinates', async () => { + const fetcher = vi.fn(async () => new Response(null, { status: 204 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + expect(await controller.open(id, 2, 1, 'open', '/Apps/A&B.app')).toBeNull() + expect(fetcher).toHaveBeenCalledWith(`${url}&application=%2FApps%2FA%26B.app`, { method: 'POST', signal: expect.any(AbortSignal) as AbortSignal }) + await controller.dispose() +}) + + +it.each(['open', 'reveal'] as const)('expires successful %s feedback after five seconds and its fade', async (action) => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1, action) + await vi.advanceTimersByTimeAsync(5000) + expect(controller.state.getSnapshot()[url]).toBe(action === 'open' ? 'opened' : 'revealed') + await vi.advanceTimersByTimeAsync(200) + expect(controller.state.getSnapshot()[url]).toBeUndefined() + await controller.dispose() +}) + +it('cancels an earlier success expiry when the next action fails', async () => { + vi.useFakeTimers() + const fetcher = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 })) + .mockResolvedValueOnce(new Response(null, { status: 500 })) + vi.stubGlobal('fetch', fetcher) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + await vi.advanceTimersByTimeAsync(4000) + await controller.open(id, 2, 1) + await vi.advanceTimersByTimeAsync(6000) + expect(controller.state.getSnapshot()[url]).toBe('error') + await controller.dispose() +}) + +it('cancels success expiry when its controller is disposed', async () => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))) + const controller = new PresentedOpenController() + await controller.open(id, 2, 1) + const state = controller.state.getSnapshot() + await controller.dispose() + await vi.advanceTimersByTimeAsync(6000) + expect(controller.state.getSnapshot()).toBe(state) +}) + + +it('owns the expiry before notifying subscribers that can dispose the controller', async () => { + vi.useFakeTimers() + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))) + const controller = new PresentedOpenController() + let disposal: Promise | undefined + const release = controller.state.subscribe(() => { + if (controller.state.getSnapshot()[url] === 'opened') disposal = controller.dispose() + }) + await controller.open(id, 2, 1) + await disposal + const state = controller.state.getSnapshot() + await vi.advanceTimersByTimeAsync(6000) + expect(controller.state.getSnapshot()).toBe(state) + release() +}) diff --git a/packages/client/ui-deliverables/tests/present-open.host.spec.ts b/packages/client/ui-deliverables/tests/present-open.host.spec.ts index 072529cdde..b313ee0aa8 100644 --- a/packages/client/ui-deliverables/tests/present-open.host.spec.ts +++ b/packages/client/ui-deliverables/tests/present-open.host.spec.ts @@ -46,7 +46,8 @@ async function fixture() { }) ctx.provide('sessionQuery', { readEvent } as never) const opener = vi.fn(async (_request: { path: string; action?: 'reveal' }, _signal: AbortSignal) => ({ opened: true as const })) - ctx.provide('sessionController', { resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) + const applications = vi.fn(async () => [{ id: 'player', name: 'Player', default: true, icon: null }]) + ctx.provide('sessionController', { workspacePathApplications: applications, resolveAgent, openWorkspacePath: opener, workspaceDesktop: () => ({ name: 'desktop', available: true, fileManager: 'finder' }) } as never) const connection = new HostConnectionService(ctx, [], {} as BrowserAuth) const fiber = ctx.plugin({ inject: ['connection', 'sessionQuery', 'sessionController', 'workspaceFiles', 'fs', 'sandboxPolicy'], apply: registerPresentOpen }) await fiber @@ -54,7 +55,7 @@ async function fixture() { const open = (query = '?sessionId=owner&seq=7&index=0', signal?: AbortSignal) => handler.fetch(new Request( `http://localhost${PRESENT_OPEN_PATH}${query}`, { method: 'POST', signal: signal ?? null }, )) - return { root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } + return { applications, root, cwd, ctx, fiber, file, session, readEvent, open, opener, handler, resolveAgent } } describe('Presented workspace file native open route', () => { @@ -62,7 +63,7 @@ describe('Presented workspace file native open route', () => { const { cwd, open, file, fiber, opener, handler, ctx } = await fixture() const source = await realpath(join(cwd, file.path)) expect(presentedFileUrl(SessionId('owner'), 7, 0)).toBe('api/present.open?sessionId=owner&seq=7&index=0') - expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(404) + expect((await handler.fetch(new Request(`http://localhost${PRESENT_OPEN_PATH}`))).status).toBe(400) expect((await handler.fetch(new Request('http://localhost/api/present.download?sessionId=owner&seq=7&index=0'))).status).toBe(404) for (const contents of ['current source', 'edited source']) { await writeFile(source, contents) @@ -233,3 +234,19 @@ it('uses the deployment workspace root when the viewed Session has no cwd', asyn expect((await open()).status).toBe(204) expect(opener.mock.lastCall?.[0].path).toBe(await realpath(join(cwd, file.path))) }) + + +it('queries handlers only after file authorization and forwards explicit application choices', async () => { + const { handler, applications, opener, open, ctx } = await fixture() + const url = 'http://localhost/api/present.open?sessionId=owner&seq=7&index=0' + const response = await handler.fetch(new Request(url)) + expect(response.status).toBe(200) + expect(await response.json()).toEqual([{ id: 'player', name: 'Player', default: true, icon: null }]) + expect(opener).not.toHaveBeenCalled() + expect((await open('?sessionId=owner&seq=7&index=0&application=player')).status).toBe(204) + expect(opener.mock.lastCall?.[0]).toMatchObject({ application: 'player' }) + expect((await handler.fetch(new Request(url.replace('index=0', 'index=999')))).status).toBe(404) + vi.spyOn(ctx.fs, 'processPathFromHostPath').mockReturnValue(undefined) + expect((await handler.fetch(new Request(url))).status).toBe(422) + expect(applications).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx index 28207b5a29..62a017f33a 100644 --- a/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/presented-file-card.client.spec.tsx @@ -13,66 +13,9 @@ const props = () => ({ host: { name: 'remote-desktop', available: true, fileManager: 'finder' as const }, phase: undefined, onPreview: vi.fn(), - onAction: vi.fn(), + actions: , t: makeTranslate(en), }) - -it.each([ - ['finder', 'Show in Finder'], ['explorer', 'Show in File Explorer'], ['directory', 'Open containing folder'], -] as const)('uses the Host %s action and closes the menu after selection', (fileManager, label) => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - fireEvent.click(view.getByRole('menuitem', { name: new RegExp(label) })) - expect(p.onAction).toHaveBeenCalledWith('reveal') - expect(view.queryByRole('menu')).toBeNull() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - fireEvent.click(view.getByRole('menuitem', { name: /Open in default app/ })) - expect(p.onAction).toHaveBeenLastCalledWith('open') - expect(p.onAction).toHaveBeenCalledTimes(2) -}) - -it('dismisses the menu with Escape or an outside click without launching anything', () => { - const p = props() - const view = render() - const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) - fireEvent.click(trigger) - fireEvent.keyDown(document, { key: 'Escape' }) - expect(view.queryByRole('menu')).toBeNull() - fireEvent.click(trigger) - fireEvent.pointerDown(document.body) - expect(view.queryByRole('menu')).toBeNull() - expect(p.onAction).not.toHaveBeenCalled() -}) - -it.each(['opening', 'revealing'] as const)('keeps sidebar previews available while the native action is %s', (phase) => { - const p = props() - const view = render() - expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) - fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) - fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) - expect(p.onPreview).toHaveBeenCalledTimes(2) - expect(p.onAction).not.toHaveBeenCalled() -}) - -it('keeps the native menu disabled until a desktop is available', () => { - const p = props() - const view = render() - expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) - expect((view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) as HTMLButtonElement).disabled).toBe(false) - view.rerender() - expect((view.getByRole('button', { name: 'More file actions for out/report.pdf' }) as HTMLButtonElement).disabled).toBe(true) -}) - -it('opens the right sidebar from either the card or its primary button', () => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) - fireEvent.click(view.getByRole('button', { name: 'Open out/report.pdf in sidebar' })) - expect(p.onPreview).toHaveBeenCalledTimes(2) - expect(p.onAction).not.toHaveBeenCalled() -}) - it('localizes reveal failures and accurately reports a directory-only action', () => { const p = props() const view = render() @@ -84,36 +27,12 @@ it('localizes reveal failures and accurately reports a directory-only action', ( }) -it('supports keyboard selection and returns focus to the trigger on Escape', () => { - const view = render() - const trigger = view.getByRole('button', { name: 'More file actions for out/report.pdf' }) - fireEvent.click(trigger) - const items = view.getAllByRole('menuitem') - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) - expect(document.activeElement).toBe(items[1]) - fireEvent.keyDown(document.activeElement!, { key: 'ArrowDown' }) - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'End' }) - expect(document.activeElement).toBe(items[1]) - fireEvent.keyDown(document.activeElement!, { key: 'ArrowUp' }) - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'Home' }) - expect(document.activeElement).toBe(items[0]) - fireEvent.keyDown(document.activeElement!, { key: 'Escape' }) - expect(document.activeElement).toBe(trigger) -}) - - it('shows the basename while retaining the full location for hover and actions', () => { const p = props() const path = '/work/reports/result.pdf' const view = render() expect(view.getByTitle(path)).toBeTruthy() expect(view.getByText('result.pdf')).toBeTruthy() - fireEvent.click(view.getByRole('button', { name: `More file actions for ${path}` })) - fireEvent.click(view.getByRole('menuitem', { name: 'Open in default app' })) - expect(p.onAction).toHaveBeenCalledWith('open') view.rerender() expect(view.getByTitle('/work/out/report.pdf')).toBeTruthy() expect(view.getByText('report.pdf')).toBeTruthy() @@ -130,31 +49,6 @@ it.each([ }) -it('does not reopen a menu after a shared native request settles', () => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - expect(view.getByRole('menu')).toBeTruthy() - view.rerender() - expect(view.queryByRole('menu')).toBeNull() - view.rerender() - expect(view.queryByRole('menu')).toBeNull() -}) - -it('keeps focus on the available preview button after selecting a native action', () => { - const p = props() - const view = render() - fireEvent.click(view.getByRole('button', { name: 'More file actions for out/report.pdf' })) - const item = view.getByRole('menuitem', { name: 'Show in Finder' }) - item.focus() - fireEvent.click(item) - view.rerender() - const preview = view.getByRole('button', { name: 'Open out/report.pdf in sidebar' }) - expect(document.activeElement).toBe(preview) - view.rerender() - expect(document.activeElement).toBe(preview) -}) - it.each([en, zh])('distinguishes directory-only progress and errors in each locale', (dictionary) => { const p = { ...props(), t: makeTranslate(dictionary) } const view = render() @@ -164,3 +58,15 @@ it.each([en, zh])('distinguishes directory-only progress and errors in each loca view.rerender() expect(view.getByText(dictionary['presented.directoryError'])).toBeTruthy() }) + + +it('renders the supplied action independently from the card preview', () => { + const p = props() + const native = vi.fn() + const view = render(Native file action} />) + fireEvent.click(view.getByRole('button', { name: 'Native file action' })) + expect(native).toHaveBeenCalledOnce() + expect(p.onPreview).not.toHaveBeenCalled() + fireEvent.click(view.getByRole('button', { name: 'Preview out/report.pdf in sidebar' })) + expect(p.onPreview).toHaveBeenCalledOnce() +}) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 9cdc89ec65..2af2a12b4a 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -1,5 +1,6 @@ // @vitest-environment jsdom /** The review tab type: its addresses, its store, the row pairing of the split view, and the states its body draws. */ +import { renderFileActions } from './file-actions.tsx' import { useSyncExternalStore } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -158,11 +159,17 @@ describe('ReviewTab', () => { loadChangesSummary: vi.fn(() => Promise.resolve()), loadChangesDiff: vi.fn(() => Promise.resolve()), reloadPresentedHost: vi.fn(() => Promise.resolve()), - openChanged: vi.fn(() => Promise.resolve()), + openChanged: vi.fn(() => Promise.resolve(null)), + } + const sessions: SessionListState = { + ids: [SESSION], + byId: { [SESSION]: { id: SESSION, displayTitle: 'Workspace', cwd: options.cwd ?? '/work/app', + running: false, retainedBy: {}, blank: false, updatedAt: 0 } }, + phase: 'ready', projectionsBySession: {}, } - const sessions = { byId: { [SESSION]: { cwd: options.cwd ?? '/work/app' } } } as unknown as SessionListState const navigation = { address: options.address ?? ADDRESS, params: options.params, revision: options.revision ?? 1 } const runtime = { + renderSlot: renderFileActions, useTabInfo: () => ({ sidebar: { expanded: true, fullscreen: false }, panel: { id: 'pane-1' }, tab: { id: TAB, kind: 'changes-review', contentId: options.address ?? ADDRESS, title: 'Review', visible: true, navigation, signal: aborter.signal, actions: tabActions }, @@ -177,7 +184,7 @@ describe('ReviewTab', () => { usePresentedHost: hookOf(controller.host), t: makeTranslate(options.locale ?? en), ...injected, - } as unknown as ReviewTabProps + } as ReviewTabProps const view = render() return { view, injected, tabActions, store, aborter, summaries, diffs, controller, @@ -313,19 +320,18 @@ describe('ReviewTab', () => { expect(view.container.querySelector('[data-review-tool="open-file"] svg')?.getAttribute('width')).toBe('12') fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in sidebar' })) expect(tabActions.openResource).toHaveBeenCalledWith(fileAddressFor(SESSION, '/work/app', '/tmp/out/big.bin')) - fireEvent.click(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' })) - expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1) + fireEvent.click(view.getByRole('button', { name: 'Native file action' })) + expect(injected.openChanged).toHaveBeenCalledWith('viewed', 5, 1, 'open', undefined) act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'opening' }) }) - expect((view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }) as HTMLButtonElement).disabled).toBe(true) + expect((view.getByRole('button', { name: 'Native file action' }) as HTMLButtonElement).disabled).toBe(true) act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'error' }) }) - expect(view.getByRole('button', { name: 'Open ~/out/big.bin in default app' }).hasAttribute('data-error')).toBe(true) act(() => { controller.state.set({ 'api/changes.open?sessionId=viewed&seq=5&index=1': 'nativeUnavailable' }) }) - expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() + expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull() act(() => { controller.state.set({}) controller.host.set({ name: 'server', available: false, fileManager: null }) }) - expect(view.queryByRole('button', { name: 'Open ~/out/big.bin in default app' })).toBeNull() + expect(view.queryByRole('button', { name: 'Native file action' })).toBeNull() expect(view.getByText(en['changes.oversized'])).toBeTruthy() }) diff --git a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts index 6bd7af1bc5..9d9bb1cbc4 100644 --- a/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts +++ b/packages/client/ui-deliverables/tests/turn-tail-spacing.client.spec.ts @@ -18,7 +18,6 @@ describe('deliverables layout', () => { expect(read('PresentedFileCard.tsx')).toMatch(//) expect(deliveries).toMatch(/\.fileName\s*\{[^}]*font-size:\s*13px;[^}]*line-height:\s*20px/s) expect(deliveries).toMatch(/\.description\s*\{[^}]*font-size:\s*10px;[^}]*line-height:\s*16px/s) - expect(deliveries).toMatch(/\.open\s*\{[^}]*padding:\s*4px 8px;[^}]*font-size:\s*12px;[^}]*line-height:\s*18px/s) expect(deliveries).toMatch(/\.presented\s*\{[^}]*gap:\s*10px/s) }) diff --git a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx index 2037b3edd7..ed033999aa 100644 --- a/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx +++ b/packages/client/ui-directory-picker-native/tests/client-flow.client.spec.tsx @@ -14,10 +14,10 @@ const desktopIpc = await vi.hoisted(async () => { const path = await import('node:path') // Electron belongs to the Desktop app; resolve its mock from that workspace. const electron = createRequire(path.resolve(import.meta.dirname, '../../../../apps/desktop/package.json')).resolve('electron') - return { invoke: vi.fn(), electron } + return { invoke: vi.fn(), on: vi.fn(), electron } }) vi.mock(desktopIpc.electron, () => ({ - ipcRenderer: { invoke: desktopIpc.invoke }, + ipcRenderer: { invoke: desktopIpc.invoke, on: desktopIpc.on }, contextBridge: { exposeInMainWorld: (name: string, value: unknown) => { vi.stubGlobal(name, value) } }, })) vi.mock('../../../../apps/desktop/src/preload-platform.ts', () => ({ markDocumentPlatform: vi.fn(), syncWindowFullscreen: vi.fn() })) @@ -25,7 +25,7 @@ vi.mock('../../../../apps/desktop/src/preload-theme.ts', () => ({ syncNativeThem vi.mock('../../../../apps/desktop/src/preload-windows.ts', () => ({ syncWindowsAppearance: vi.fn() })) vi.mock('../../../../apps/desktop/src/preload-mandatory-overlay.ts', () => ({ installMandatoryUpdateOverlay: vi.fn() })) -afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); desktopIpc.invoke.mockReset() }) +afterEach(() => { cleanup(); vi.unstubAllGlobals(); vi.restoreAllMocks(); desktopIpc.invoke.mockReset(); desktopIpc.on.mockReset() }) const HOLES = ['conversation.hero.workspace.directoryFlow', 'sidebar.workspaces.directoryFlow'] as const diff --git a/packages/client/ui-dockkit/README.i18n.yaml b/packages/client/ui-dockkit/README.i18n.yaml index bbf8417672..333cd57389 100644 --- a/packages/client/ui-dockkit/README.i18n.yaml +++ b/packages/client/ui-dockkit/README.i18n.yaml @@ -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-dockkit/README.md -README.md: f41668ad2eb6c16e13deb5fcc554c8717811a468 -README.zh.md: 6a4c70a1ddb46b75b614e9e63eaddd89f3641ac3 +README.md: 613cdbcb105108eb848160f1cb6bc8ee28d39fac +README.zh.md: 77b256f01dd46aea5144e9f8da644fd39330f187 diff --git a/packages/client/ui-dockkit/README.md b/packages/client/ui-dockkit/README.md index f41668ad2e..613cdbcb10 100644 --- a/packages/client/ui-dockkit/README.md +++ b/packages/client/ui-dockkit/README.md @@ -50,13 +50,15 @@ Everything host-specific arrives through props: | `TabRenderer` | one tab's body (`renderTab`), drawn flush to the pane's edges and the unbordered strip's bottom edge with the insets it chooses, and optionally what its chip or panel header shows as a title (`renderTabTitle`, falling back to the record's `title`); the embedder dispatches on `tab.kind` | | `DockIntents` | the settled results of every gesture | -`DockController` satisfies `DockIntents` as written, so the simplest embedding hands the controller straight to `DockSurface`. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. +`DockController` satisfies `DockIntents` as written, so an embedding can pass it to `DockLayout` for retained Sidebar layouts or `DockSurface` for recursive split trees. An embedder that routes through its own store implements the same method names instead. Three props carry control policy rather than gestures: `canSplit` (surface-wide, the pane budget; disables the split control with `splitPaneDisabled`), `canAddTab(paneId)` (per pane, omits the add control; leave it out to draw one in every pane), and `canCloseTab(tabId)` (per tab, withholds the chip's close control and the menu's close item together; leave it out to keep every tab closable). Hiding the add control moves nothing else in the strip, and a withheld close moves nothing in the chip — the close control paints over the title's end rather than beside it. A pane's lone chip whose close is withheld draws quiet — no capsule, no hover fill — since there is nothing to select against and nothing to do to it. The kit adds one policy of its own, the room rule below, which disables a pane's split control with `splitPaneNarrow`; `onRoom(fits)` reports its readings so an embedder splitting programmatically can honour the same rule. `dropZones="horizontal"` offers two half-pane hints; once budget or width forbids another split, the whole body accepts a move. A hint is a dashed card inset 8px inside its region, showing the zone's glyph and `labels.dropZone[zone]`; the preview layer covers all tab-body content, while the card under the pointer takes the accent and its neighbour stays a quiet outline. `minPaneFraction` sets the preview minimum, and `planResizeSplit` accepts the same minimum for the committed operation. The Sidebar uses 0.2 and enforces two panes in its own store. The generic engine retains its tree and other split directions. `hideSplitWhenBlocked` hides a blocked split control — pane budget spent or pane too narrow — instead of rendering it disabled; its default is false. A tab's `kind` is an opaque string. Seeded tabs are factories (`DockControllerOptions`), so what a fresh pane contains is the embedder's decision, not this package's. Content identity is the pair (`kind`, `contentId`): `findContentTab(state, contentId, kind?)` finds the tab showing it anywhere and `findPaneContentTab(state, paneId, contentId, kind?)` within one pane, and `planOpenContent` focuses that tab instead of opening another unless told `revealIfOpened: false`; an explicit `index` seats a new tab at a strip slot rather than at the end. -`DockSurface` is the docked area. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. +`DockLayout`, used by the Sidebar, keeps each tab in a stable DOM cell across selection, pane moves and floating. It accepts one docked pane or two horizontal panes; CSS Grid resolves their widths. `keepMounted(tab)` retains visited bodies and `active` controls Session visibility without reparenting content. + +`DockSurface` is the docked area for the separate recursive renderer. Chrome around it — a rail, a collapsed presentation, any history controls — belongs to the embedder, which reads `state.expanded` and decides; the kit ships no undo/redo control of its own. Surface-wide controls the embedder does want on the surface go through the `chrome` prop, which the kit places at the far end of the top-right pane's tab strip (the last child of every row split, the first of every column split), so a surface needs no header row of its own. `FloatLayer` owns its own gestures and positions panels in viewport coordinates, so it may be mounted anywhere, including a portal. ## Interaction rules worth keeping diff --git a/packages/client/ui-dockkit/README.zh.md b/packages/client/ui-dockkit/README.zh.md index 6a4c70a1dd..77b256f01d 100644 --- a/packages/client/ui-dockkit/README.zh.md +++ b/packages/client/ui-dockkit/README.zh.md @@ -50,13 +50,15 @@ kind: "package-reference" | `TabRenderer` | 一个 tab 的正文(`renderTab`),贴着格的边缘和(不带边线的)tab 条底边绘制、自己决定留白,以及可选的 chip 或浮窗头部显示的标题(`renderTabTitle`,回退到记录的 `title`);嵌入方按 `tab.kind` 分发 | | `DockIntents` | 每次手势落定的结果 | -`DockController` 原样满足 `DockIntents`,所以最简单的嵌入就是把 controller 直接交给 `DockSurface`。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 +`DockController` 原样满足 `DockIntents`,因此嵌入方可将它交给 `DockLayout` 来保活 Sidebar 布局,或交给 `DockSurface` 来渲染递归分割树。经由自己 store 路由的嵌入方则实现同名方法。有三个 props 承载的是控制策略而非手势:`canSplit`(整面有效,即格预算;用 `splitPaneDisabled` 禁用分栏控件)、`canAddTab(paneId)`(按格,省略添加控件;不传则每格都画)与 `canCloseTab(tabId)`(按 tab,把 chip 的关闭控件和菜单的关闭项一并收起;不传则每个 tab 都可关闭)。隐藏添加控件不会移动 tab 条里的其它任何东西,收起关闭也不会移动 chip 里的任何东西——关闭控件压在标题末端之上而非并排。某格仅剩的一个 chip 在关闭被收起时画成安静样式——没有胶囊底色,没有悬停填充——因为既没有别的 tab 可供选择,也没有任何可对它做的事。套件自己再加一条策略,即下文的空间规则,它用 `splitPaneNarrow` 禁用某格的分栏控件;`onRoom(fits)` 上报其读数,让以编程方式分栏的嵌入方能遵守同一规则。 `dropZones="horizontal"` 提供左右两个半区提示;预算或宽度不允许再拆时,正文整格接收移动。提示是一张内缩 8px 的虚线卡片,显示该落区的图形和 `labels.dropZone[zone]`;预览层覆盖全部 tab 正文,指针所在的卡片取强调色,另一张保持安静的轮廓。`minPaneFraction` 控制预览的最小比例,`planResizeSplit` 接受相同最小值以约束提交;Sidebar使用0.2并在自己的store限制两格。通用引擎仍保留原有树与其它分割方向。 `hideSplitWhenBlocked` 在分栏被阻止时(窗格预算已满或格太窄)直接隐藏分栏控件而不是渲染禁用态,默认值为 false。 tab 的 `kind` 是不透明字符串。种子 tab 是工厂(`DockControllerOptions`),因此新格里放什么由嵌入方决定,与本包无关。内容身份是二元组(`kind`、`contentId`):`findContentTab(state, contentId, kind?)` 在任意位置找到展示它的 tab,`findPaneContentTab(state, paneId, contentId, kind?)` 在一个格内找;`planOpenContent` 会聚焦该 tab 而非再开一个,除非被告知 `revealIfOpened: false`;显式的 `index` 把新 tab 放到 tab 条的某个位置而非末尾。 -`DockSurface` 是停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 +Sidebar 使用的 `DockLayout` 在选中项变化、跨格移动与浮动期间,把每个 tab 保留在稳定的 DOM 容器中。它接受一个停靠格或左右两个格,由 CSS Grid 计算宽度。`keepMounted(tab)` 保留已访问正文,`active` 控制 Session 可见性,不改变内容的父子关系。 + +`DockSurface` 是独立递归渲染器的停靠区。它周围的 chrome——轨道、折叠形态、任何历史控件——属于嵌入方,由嵌入方读取 `state.expanded` 后自行决定;套件不自带撤销/重做控件。嵌入方确实想放到面上的整面控件通过 `chrome` prop 传入,套件把它放在右上格 tab 条的最末端(每个横向分裂的最后一个子节点、每个纵向分裂的第一个子节点),因此停靠面不需要自己的标题行。`FloatLayer` 拥有自己的手势并以视口坐标定位浮窗,因此可以挂在任何位置,包括 portal 里。 ## 值得保留的交互规则 diff --git a/packages/client/ui-dockkit/src/components/DockSurface.tsx b/packages/client/ui-dockkit/src/components/DockSurface.tsx index 6c3e0ced4d..17ca70f0c9 100644 --- a/packages/client/ui-dockkit/src/components/DockSurface.tsx +++ b/packages/client/ui-dockkit/src/components/DockSurface.tsx @@ -21,6 +21,7 @@ import { import { fitOf, measurePaneFits, paneElements, sameFits } from './measure.ts' import { useGesture } from './pointer.ts' import { PaneTree, type SizePreview } from './PaneTree.tsx' +import { TabLayout, type TabRetention } from './TabLayout.tsx' import type { PaneCallbacks, SplitBlock } from './render.ts' import css from './dockkit.module.css' @@ -161,10 +162,11 @@ function sameSizes(a: readonly number[], b: readonly number[]): boolean { } /** The split tree and the gestures over it. */ -export function DockSurface({ +function Surface({ state, canSplit, canAddTab, canCloseTab, intents, labels, renderTab, renderTabTitle, renderTabMenuItems, chrome, onRoom, + draw, dropZones = 'edges', minPaneFraction = MIN_PANE_FRACTION, hideSplitWhenBlocked = false, -}: DockSurfaceProps): ReactNode { +}: DockSurfaceProps & { readonly draw: (callbacks: PaneCallbacks, preview: SizePreview | undefined) => ReactNode }): ReactNode { const surface = useRef(null) const [preview, setPreview] = useState(NO_PREVIEW) const [fits, setFits] = useState(NO_FITS) @@ -289,7 +291,26 @@ export function DockSurface({ return (
- + {draw(callbacks, preview.sizes)}
) } + +/** Recursive docked layout; floating content is rendered separately by FloatLayer. */ +export function DockSurface(props: DockSurfaceProps): ReactNode { + return + } /> +} + +/** Horizontal Sidebar layout with stable tab containers across docking and floating. */ +export type DockLayoutProps = DockSurfaceProps & TabRetention + +/** + * Render one pane or two horizontal panes, and their floats, in one stable content tree. + * @param props - layout, gestures and lazy body-retention policy. + * @returns the layout; the containing ancestors must not clip or establish a fixed-position containing block. + */ +export function DockLayout(props: DockLayoutProps): ReactNode { + return + } /> +} diff --git a/packages/client/ui-dockkit/src/components/FloatLayer.tsx b/packages/client/ui-dockkit/src/components/FloatLayer.tsx index a1efe4ce40..5f512423bd 100644 --- a/packages/client/ui-dockkit/src/components/FloatLayer.tsx +++ b/packages/client/ui-dockkit/src/components/FloatLayer.tsx @@ -18,7 +18,7 @@ import type { PointerEvent as ReactPointerEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconCloseOutlineRegular, IconPanelLeftOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' import type { DockIntents, DockLabels, TabRenderer } from '../contract/adapter.ts' -import type { FloatRect, LayoutState, PaneId, TabId } from '../contract/types.ts' +import type { FloatRect, LayoutState, PaneId, TabId, TabRecord } from '../contract/types.ts' import { FLOAT_MIN_SIZE } from '../engine/constraints.ts' import { movedRect, resizedRect } from '../engine/geometry.ts' import { floatRect, getPane, getTab, onlyTabId } from '../engine/tree.ts' @@ -65,8 +65,13 @@ function raised(state: LayoutState, paneId: PaneId): boolean { return state.activePaneId === paneId && state.floats.at(-1) === paneId } -/** Every floating panel, in z order. */ -export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { +/** + * Shared move/resize gestures; changing layout mode never owns the content DOM. + * @param state - committed layout and float stacking order. + * @param intents - gesture settlements. + * @returns the current preview and focus/gesture callbacks. + */ +export function useFloatGestures(state: LayoutState, intents: DockIntents) { const [preview, setPreview] = useState<{ paneId: PaneId; rect: FloatRect } | undefined>(undefined) const begin = useGesture(() => { setPreview(undefined) }) @@ -93,6 +98,54 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, }) } + return { preview, raise, drag } +} + +/** + * Header-only controls, usable above either retained or visibility-mounted bodies. + * @param props - owning pane/tab, localized labels and gesture callbacks. + * @returns the float header without a content container. + */ +export function FloatHeader({ paneId, tab, labels, intents, renderTabTitle, canCloseTab, drag }: { + readonly paneId: PaneId + readonly tab: TabRecord + readonly labels: DockLabels + readonly intents: DockIntents + readonly renderTabTitle: TabRenderer | undefined + readonly canCloseTab: ((id: TabId) => boolean) | undefined + readonly drag: ReturnType['drag'] +}): ReactNode { + return ( +
{ drag('move', paneId, event) }}> +
+ {renderTabTitle?.(tab) ?? tab.title} +
+
+ + + + {(canCloseTab?.(tab.id) ?? true) && ( + + + + )} +
+ ) +} + +/** Every floating panel, in z order. */ +export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, canCloseTab }: FloatLayerProps): ReactNode { + const { preview, raise, drag } = useFloatGestures(state, intents) + return ( <> {state.floats.map((paneId, depth) => { @@ -117,42 +170,7 @@ export function FloatLayer({ state, intents, labels, renderTab, renderTabTitle, }} onPointerDown={() => { raise(paneId) }} > -
{ drag('move', paneId, event) }} - > -
- {renderTabTitle?.(tab) ?? tab.title} -
-
- - - - {(canCloseTab?.(tab.id) ?? true) && ( - - - - )} -
+
{renderTab(tab)}
boolean + /** Whether this layout's Session is on screen; defaults to true. */ + readonly active?: boolean +} + +interface LayoutProps extends TabRetention { + readonly state: LayoutState + readonly callbacks: PaneCallbacks + readonly preview: SizePreview | undefined + readonly intents: DockIntents +} + +interface TabHostProps extends LayoutProps { + readonly tab: TabRecord + readonly pane: PaneNode + readonly column: number + readonly floats: ReturnType + readonly focusRequest: MutableRefObject<{ readonly tabId: TabId; readonly origin: Element | null } | undefined> +} + +/** A tab's ancestors stay identical across selection, pane moves and floating. */ +function TabHost({ state, callbacks, intents, tab, pane, column, floats, focusRequest, keepMounted, + active = true }: TabHostProps): ReactNode { + const floating = pane.host === 'float' + const selected = floating || pane.activeTabId === tab.id + const visible = active && selected && (floating || state.expanded) + const retained = keepMounted?.(tab) ?? false + const [visited, setVisited] = useState(visible) + if (visible && !visited) setVisited(true) + const host = useRef(null) + const body = useRef(null) + useLayoutEffect(() => { + // Both refs target unconditional descendants attached before these effects. + const section = host.current as HTMLElement + section.inert = !visible + const focused = document.activeElement + if (!visible && focused instanceof HTMLElement && section.contains(focused)) focused.blur() + const request = focusRequest.current + if (!visible || request?.tabId !== tab.id) return + focusRequest.current = undefined + // Preserve deliberate focus taken by another control or the newly shown body. + if (focused !== null && focused !== document.body && focused !== document.documentElement && focused !== request.origin) return + const strip = section.querySelector('[data-dockkit-strip]') + const chip = [...strip?.querySelectorAll('[data-dockkit-tab]') ?? []] + .find(element => element.dataset.dockkitTab === tab.id) + chip?.focus({ preventScroll: true }) + }, [visible, tab.id, focusRequest]) + // Electron emits a non-bubbling focus event on the webview element. + useEffect(() => { + const element = body.current as HTMLDivElement + const focus = (): void => { + if (!visible) return + if (floating) floats.raise(pane.id) + else if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) + } + element.addEventListener('focus', focus, true) + return () => { element.removeEventListener('focus', focus, true) } + }, [visible, floating, floats, pane.id, state.activePaneId, callbacks]) + const lifted = floating && floats.preview?.paneId === pane.id ? floats.preview.rect : undefined + const rect = floating ? lifted ?? floatRect(pane) : undefined + const depth = lifted === undefined ? state.floats.indexOf(pane.id) + 1 : state.floats.length + 1 + return ( + + ) +} + +/** + * One or two horizontal panes; CSS resolves content sizes from track fractions. + * @param props - committed layout, gesture preview and body retention policy. + * @returns stable tab containers, including floating tabs. + * @throws if the docked tree is not one pane or two horizontal panes. + */ +export function TabLayout(props: LayoutProps): ReactNode { + const { state, callbacks, preview } = props + const focusRequest = useRef<{ readonly tabId: TabId; readonly origin: Element | null }>() + const tabCallbacks: PaneCallbacks = { + ...callbacks, + onFocusTab: (tabId) => { + // Only changing the selected body replaces its strip; pane-only focus does not. + focusRequest.current = findTabPane(state, tabId).activeTabId === tabId + ? undefined : { tabId, origin: document.activeElement } + callbacks.onFocusTab(tabId) + }, + } + const root = getNode(state, state.rootId) + if (root.kind === 'split' && (root.axis !== 'row' || root.children.length !== 2)) { + throw new Error('DockLayout requires one pane or two horizontally split panes') + } + const panes = root.kind === 'pane' ? [root] : root.children.map(id => getPane(state, id)) + const sizes = root.kind === 'split' ? preview?.splitId === root.id ? preview.sizes : root.sizes : [1] + const floats = useFloatGestures(state, props.intents) + const columns = sizes.map(size => `minmax(0, ${size}fr)`).join(' 0px ') + return ( +
+ {/* Logical order changes must not make React move a connected webview's ancestor. */} + {Object.values(state.tabs).sort((a, b) => a.id.localeCompare(b.id)).map((tab) => { + const pane = findTabPane(state, tab.id) + return candidate.id === pane.id)} floats={floats} focusRequest={focusRequest} /> + })} + {panes.filter(pane => pane.tabs.length === 0).map(pane => ( +
+ +
+ ))} + {root.kind === 'split' &&
{ callbacks.onDividerPressed(root.id, 0, event) }} />} +
+ ) +} diff --git a/packages/client/ui-dockkit/src/components/TabPanel.tsx b/packages/client/ui-dockkit/src/components/TabPanel.tsx index ae7d2e6fa7..1088e71399 100644 --- a/packages/client/ui-dockkit/src/components/TabPanel.tsx +++ b/packages/client/ui-dockkit/src/components/TabPanel.tsx @@ -208,8 +208,8 @@ function splitBlockedTitle(labels: PaneCallbacks['labels'], block: SplitBlock): } } -/** The pane's tab strip, split control, and body. */ -export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { +/** @param props - pane state and gestures. @returns its tab strip without a content container. */ +export function TabStrip({ state, pane, callbacks }: TabPanelProps): ReactNode { // The open context menu and the chip that opened it; the menu positions // itself against that chip from its portal. const [menu, setMenu] = useState<{ readonly tabId: TabId; readonly anchor: HTMLElement } | undefined>(undefined) @@ -218,15 +218,11 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { const stripTabs = useRef(null) useStripScrollFades(stripTabs, pane.tabs) useActiveChipInView(stripTabs, chips, pane.tabs, pane.activeTabId) - const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) const block = callbacks.splitBlock(pane.id) const target = callbacks.dropTarget const stripIndex = target !== undefined && target.kind === 'strip' && target.paneId === pane.id ? target.index : undefined - const zone = target !== undefined && target.kind === 'zone' && target.paneId === pane.id - ? target.zone - : undefined /** Select a tab from a click or a key, unless it is the active pane's selected tab already: that changes nothing. */ const activate = (tabId: TabId): void => { @@ -241,187 +237,196 @@ export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { chip.focus() } + return ( +
+
+ {pane.tabs.map((tabId, index) => { + const tab = getTab(state, tabId) + const selected = tabId === pane.activeTabId + const closable = callbacks.canCloseTab(tabId) + // A pane's lone unclosable chip is a label, not a choice: there is + // no other tab to select against and nothing to do to it. + const quiet = !closable && pane.tabs.length === 1 + return ( + + {(index > 0 || stripIndex === index) && ( +
+ )} +
{ + if (element === null) chips.delete(tabId) + else chips.set(tabId, element) + }} + // Focus lands on click, not on press: a state change between + // pointerdown and the first pointermove rebuilds this subtree, + // and Chromium cancels the pointer when the pressed element is + // replaced — which would abandon every drag. A drag that ends + // elsewhere fires no click, and its own operation carries focus. + onPointerDown={(event) => { + // A secondary press is the menu, never a drag. + if (event.button === 2) return + callbacks.onTabPressed(tabId, event) + }} + onClick={(event) => { + event.stopPropagation() + activate(tabId) + }} + onKeyDown={(event) => { + // Keys on the chip's nested close control are that control's. + if (event.target !== event.currentTarget) return + const next = chipToFocus(event.key, pane.tabs, tabId) + if (next !== undefined) { + event.preventDefault() + focusChip(next) + return + } + if (selects(event.key)) { + event.preventDefault() + activate(tabId) + } + }} + onContextMenu={(event) => { + event.preventDefault() + const anchor = event.currentTarget + setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) + }} + > + {callbacks.renderTabTitle?.(tab) ?? tab.title} + {closable && ( + + )} + {menu?.tabId === tabId && ( + { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined} + onDismiss={() => { setMenu(undefined) }} + extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} + /> + )} +
+ + ) + })} + {stripIndex === pane.tabs.length &&
} +
+ {callbacks.canAddTab(pane.id) && ( + + + + )} +
+ {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( + + + + )} + {/* The embedder's surface-wide controls, in the top-right pane only: the + strip is the surface's top edge, and this pane's end is its corner. */} + {pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && ( + // The embedder's controls report their own intents; the pane's + // click-to-focus must not add a focus entry to each of them. +
{ event.stopPropagation() }} + > + {callbacks.chrome} +
+ )} +
+ ) +} + +/** @param props - target pane and drag preview. @returns body-local drop feedback, or nothing. */ +export function PaneDropHints({ pane, callbacks }: Pick): ReactNode { + const target = callbacks.dropTarget + const zone = target?.kind === 'zone' && target.paneId === pane.id ? target.zone : undefined + if (zone === undefined) return null + return <> +
+ {callbacks.horizontalDrops && zone !== 'center' + ? <> + + + + : } + +} + +/** @param props - pane state and gestures. @returns one pane in the recursive, visibility-mounted layout. */ +export function TabPanel({ state, pane, callbacks }: TabPanelProps): ReactNode { + const active = pane.activeTabId === undefined ? undefined : getTab(state, pane.activeTabId) return (
{ - if (state.activePaneId === pane.id) return - callbacks.onFocusPane(pane.id) + if (state.activePaneId !== pane.id) callbacks.onFocusPane(pane.id) }} > -
-
- {pane.tabs.map((tabId, index) => { - const tab = getTab(state, tabId) - const selected = tabId === pane.activeTabId - const closable = callbacks.canCloseTab(tabId) - // A pane's lone unclosable chip is a label, not a choice: there is - // no other tab to select against and nothing to do to it. - const quiet = !closable && pane.tabs.length === 1 - return ( - - {(index > 0 || stripIndex === index) && ( -
- )} -
{ - if (element === null) chips.delete(tabId) - else chips.set(tabId, element) - }} - // Focus lands on click, not on press: a state change between - // pointerdown and the first pointermove rebuilds this subtree, - // and Chromium cancels the pointer when the pressed element is - // replaced — which would abandon every drag. A drag that ends - // elsewhere fires no click, and its own operation carries focus. - onPointerDown={(event) => { - // A secondary press is the menu, never a drag. - if (event.button === 2) return - callbacks.onTabPressed(tabId, event) - }} - onClick={(event) => { - event.stopPropagation() - activate(tabId) - }} - onKeyDown={(event) => { - // Keys on the chip's nested close control are that control's. - if (event.target !== event.currentTarget) return - const next = chipToFocus(event.key, pane.tabs, tabId) - if (next !== undefined) { - event.preventDefault() - focusChip(next) - return - } - if (selects(event.key)) { - event.preventDefault() - activate(tabId) - } - }} - onContextMenu={(event) => { - event.preventDefault() - const anchor = event.currentTarget - setMenu(current => current?.tabId === tabId ? undefined : { tabId, anchor }) - }} - > - {callbacks.renderTabTitle?.(tab) ?? tab.title} - {closable && ( - - )} - {menu?.tabId === tabId && ( - { setMenu(undefined); callbacks.onCloseTab(tabId) } : undefined} - onDismiss={() => { setMenu(undefined) }} - extras={callbacks.renderTabMenuItems?.(tab, () => { setMenu(undefined) })} - /> - )} -
- - ) - })} - {stripIndex === pane.tabs.length &&
} -
- {callbacks.canAddTab(pane.id) && ( - - - - )} -
- {!(callbacks.hideSplitWhenBlocked && block !== undefined) && ( - - - - )} - {/* The embedder's surface-wide controls, in the top-right pane only: the - strip is the surface's top edge, and this pane's end is its corner. */} - {pane.id === callbacks.chromePaneId && callbacks.chrome !== undefined && ( - // The embedder's controls report their own intents; the pane's - // click-to-focus must not add a focus entry to each of them. -
{ event.stopPropagation() }} - > - {callbacks.chrome} -
- )} -
+
{active === undefined ?

{callbacks.labels.emptyPane}

: callbacks.renderTab(active)} - {zone !== undefined && ( - <> -
- {callbacks.horizontalDrops && zone !== 'center' - ? <> - - - - : } - - )} +
) diff --git a/packages/client/ui-dockkit/src/components/dockkit.module.css b/packages/client/ui-dockkit/src/components/dockkit.module.css index fab875c5b6..fd24348a10 100644 --- a/packages/client/ui-dockkit/src/components/dockkit.module.css +++ b/packages/client/ui-dockkit/src/components/dockkit.module.css @@ -129,6 +129,82 @@ min-height: 0; } +/* Every tab remains a sibling. Explicit tracks determine placement; order only + changes painting, so raising a float never moves a connected DOM ancestor. */ +.tabLayout { + display: grid; + flex: 1 1 auto; + grid-template-rows: minmax(0, 1fr); + min-width: 0; + min-height: 0; +} + +.tabCell, +.emptyTabHost { + display: flex; + min-width: 0; + min-height: 0; + z-index: var(--dsh-dockkit-dock-layer, 10); +} + +.tabCell[hidden] { + display: none; +} + +/* A zero-sized Grid item supplies a stacking context, not a containing block + for its fixed child. Floats share one layer and use Grid paint order within it. */ +.floatingCell { + width: 0; + height: 0; + z-index: var(--dsh-dockkit-float-layer, 60); +} + +.tabHost { + min-width: 0; + min-height: 0; + pointer-events: auto; +} + +.tabHost:not(.float), +.emptyTabHost { + background: var(--dsw-alias-bg-base); +} + +.tabCell[data-dockkit-host='dock'][data-dockkit-column='0'] > .tabHost { + border-left: 0.5px solid var(--dsw-alias-border-l4); +} + +.tabHostHeader { + flex: none; +} + +.tabHostBody { + position: relative; + min-width: 0; +} + +.tabLayoutDivider { + width: 0; + cursor: col-resize; + z-index: calc(var(--dsh-dockkit-dock-layer, 10) + 1); + pointer-events: auto; +} + +.tabLayoutDivider::before { + top: 0; + bottom: 0; + left: -0.25px; + width: 0.5px; +} + +.tabLayoutDivider::after { + top: 0; + bottom: 0; + left: -4px; + right: -4px; + background: linear-gradient(to bottom, transparent, var(--dsw-alias-label-caption) 50%, transparent) center / 1px 100% no-repeat; +} + /* A body that overflows scrolls inside its pane; nothing escapes the pane. */ .pane { display: flex; @@ -234,6 +310,7 @@ :global(html[data-platform='darwin']) .stripTabs, :global(html[data-platform='darwin']) .stripChrome, :global(html[data-platform='darwin']) .paneBody, +:global(html[data-platform='darwin']) .float, :global(html[data-platform='darwin']) .divider { -webkit-app-region: no-drag; } diff --git a/packages/client/ui-dockkit/src/components/pointer.ts b/packages/client/ui-dockkit/src/components/pointer.ts index a3b368ea9b..bd2f136bf4 100644 --- a/packages/client/ui-dockkit/src/components/pointer.ts +++ b/packages/client/ui-dockkit/src/components/pointer.ts @@ -40,6 +40,10 @@ export function followPointer(element: HTMLElement, pointerId: number, followers capturePointer(element, pointerId) const controller = new AbortController() const { signal } = controller + element.dataset.dockkitPointer = String(pointerId) + signal.addEventListener('abort', () => { + if (element.dataset.dockkitPointer === String(pointerId)) delete element.dataset.dockkitPointer + }, { once: true }) const own = (event: PointerEvent): boolean => event.pointerId === pointerId window.addEventListener('pointermove', (event) => { if (own(event)) followers.move(event) }, { signal }) window.addEventListener('pointerup', (event) => { diff --git a/packages/client/ui-dockkit/src/index.ts b/packages/client/ui-dockkit/src/index.ts index f72adb9271..0632f84fe5 100644 --- a/packages/client/ui-dockkit/src/index.ts +++ b/packages/client/ui-dockkit/src/index.ts @@ -66,3 +66,5 @@ export { DockSurface } from './components/DockSurface.tsx' export type { DockSurfaceProps } from './components/DockSurface.tsx' export { FloatLayer } from './components/FloatLayer.tsx' export type { FloatLayerProps } from './components/FloatLayer.tsx' +export { DockLayout } from './components/DockSurface.tsx' +export type { DockLayoutProps } from './components/DockSurface.tsx' diff --git a/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx new file mode 100644 index 0000000000..8a884ba72a --- /dev/null +++ b/packages/client/ui-dockkit/tests/tab-layout.client.spec.tsx @@ -0,0 +1,212 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import type { ComponentProps } from 'react' +import { TabLayout } from '../src/components/TabLayout.tsx' +import { DockController } from '../src/engine/controller.ts' +import { getNode, getPane } from '../src/engine/tree.ts' +import type { PaneCallbacks } from '../src/components/render.ts' +import type { TabId } from '../src/contract/types.ts' +import { followPointer } from '../src/components/pointer.ts' +import { seededController, TEST_LABELS } from './fixtures.client.ts' + +afterEach(() => { cleanup(); vi.restoreAllMocks() }) + +function mounted(controller = seededController(), options: Partial> = {}) { + const callbacks: PaneCallbacks = { + onFocusTab: (id) => { controller.focusTab(id); redraw() }, + onFocusPane: (id) => { controller.focusPane(id); redraw() }, + onSplitPane: vi.fn(), onAddTab: vi.fn(), onCloseTab: vi.fn(), onTabPressed: vi.fn(), onDividerPressed: vi.fn(), + splitBlock: () => undefined, canAddTab: () => true, canCloseTab: () => true, + dropTarget: undefined, draggingTabId: undefined, labels: TEST_LABELS, + renderTab: tab => , + renderTabTitle: undefined, renderTabMenuItems: undefined, + chromePaneId: getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).id, + chrome: undefined, + } + const props = () => ({ state: controller.getSnapshot().state, callbacks, intents: controller, preview: undefined, ...options }) + const view = render() + function redraw(next: typeof options = {}) { + options = { ...options, ...next } + view.rerender() + } + const node = (selector: string): HTMLElement => { + const element = view.container.querySelector(selector) + if (element === null) throw new Error(`missing ${selector}`) + return element + } + return { controller, view, redraw, callbacks, node, body: (id: TabId) => node(`[data-body="${id}"]`) } +} + +it('retains visited bodies and hands keyboard focus to the newly selected strip', () => { + const controller = seededController() + controller.setExpanded(true) + const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]! + const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + controller.focusTab(first) + const h = mounted(controller, { keepMounted: () => true }) + const original = h.body(first) + expect(h.view.container.querySelector(`[data-body="${second}"]`)).toBeNull() + const chip = h.node(`[data-dockkit-tab="${second}"]`) + chip.focus() + fireEvent.click(chip) + expect(document.activeElement).toBe(h.node(`[data-dockkit-content="${second}"] [data-dockkit-tab="${second}"]`)) + expect(h.body(first)).toBe(original) + expect(original.closest('[data-dockkit-host]')).toHaveProperty('hidden', true) + fireEvent.click(h.node(`[data-dockkit-content="${second}"] [data-dockkit-tab="${second}"]`)) + const body = h.body(second) + body.focus() + h.redraw({ active: false }) + expect(body.closest('[data-dockkit-content]')).toHaveProperty('inert', true) + expect(document.activeElement).not.toBe(body) + fireEvent.focus(body) + h.redraw({ active: true }) + expect(h.body(second)).toBe(body) + expect(body.closest('[data-dockkit-content]')).toHaveProperty('inert', false) + act(() => { controller.setExpanded(false); h.redraw() }) + expect(h.body(second)).toBe(body) +}) + +it('keeps body ancestors through pane moves, floating, raising and docking', () => { + const controller = seededController() + controller.setExpanded(true) + const tab = controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' }) + const h = mounted(controller, { keepMounted: () => true }) + const body = h.body(tab) + const host = body.closest('[data-dockkit-host]')! + act(() => { controller.splitPane(); h.redraw() }) + expect(h.view.container.querySelectorAll('[data-dockkit-pane]')).toHaveLength(2) + const state = controller.getSnapshot().state + const split = getNode(state, state.rootId) + if (split.kind !== 'split') throw new Error('expected a split') + h.redraw({ preview: { splitId: split.id, sizes: [0.3, 0.7] } }) + expect(h.node('[data-dockkit-split]').style.gridTemplateColumns).toContain('0.3fr') + fireEvent.pointerDown(h.node('[data-dockkit-divider]'), { button: 0, pointerId: 1 }) + expect(h.callbacks.onDividerPressed).toHaveBeenCalledOnce() + h.redraw({ preview: undefined }) + const secondPane = getPane(state, split.children[1]!).id + fireEvent.click(h.node(`[data-dockkit-content="${tab}"] [data-dockkit-tab="${tab}"]`)) + expect(controller.getSnapshot().state.activePaneId).toBe(split.children[0]) + act(() => { controller.focusPane(secondPane); h.redraw() }) + fireEvent.focus(body) + expect(controller.getSnapshot().state.activePaneId).toBe(split.children[0]) + act(() => { controller.focusPane(secondPane); h.redraw() }) + fireEvent.pointerDown(h.node(`[data-dockkit-content="${tab}"]`), { button: 0, pointerId: 1 }) + fireEvent.click(body) + fireEvent.focus(body) + expect(controller.getSnapshot().state.activePaneId).toBe(getPane(state, split.children[0]!).id) + act(() => { controller.placeTab(tab, secondPane, 0); h.redraw() }) + expect(h.body(tab)).toBe(body) + expect(body.closest('[data-dockkit-host]')).toBe(host) + act(() => { controller.floatTab(tab); h.redraw() }) + const floating = controller.getSnapshot().state.floats[0]! + expect(body.closest('[data-dockkit-host]')).toBe(host) + expect(host.getAttribute('data-dockkit-host')).toBe('float') + act(() => { controller.setExpanded(false); h.redraw() }) + expect(body.closest('[data-dockkit-content]')?.getAttribute('aria-hidden')).toBeNull() + fireEvent.pointerDown(h.node(`[data-dockkit-float="${floating}"]`), { pointerId: 1, button: 0 }) + fireEvent.click(h.node(`[data-dockkit-float="${floating}"]`)) + fireEvent.focus(body) + const resize = h.node('[data-dockkit-float-resize]') + fireEvent.pointerDown(resize, { pointerId: 2, button: 0, clientX: 500, clientY: 400 }) + fireEvent.pointerMove(window, { pointerId: 2, clientX: 540, clientY: 440 }) + fireEvent.pointerUp(window, { pointerId: 2, clientX: 540, clientY: 440 }) + act(() => { h.redraw(); controller.unfloatPane(floating); h.redraw() }) + expect(h.body(tab)).toBe(body) + expect(body.closest('[data-dockkit-host]')).toBe(host) +}) + +it('unmounts unretained bodies and restores focus to their replacement strip', () => { + const controller = seededController() + controller.setExpanded(true) + const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]! + const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + controller.focusTab(first) + const h = mounted(controller) + const original = h.body(first) + fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`)) + expect(original.isConnected).toBe(false) + h.redraw({ active: false }) + expect(h.view.container.querySelector('[data-body]')).toBeNull() + h.redraw({ active: true }) + const focus = vi.spyOn(HTMLElement.prototype, 'focus') + fireEvent.click(h.node(`[data-dockkit-tab="${first}"]`)) + expect(focus).toHaveBeenCalled() +}) + +it.for(['new-control', 'origin-control', 'document', 'no-focus'] as const)('transfers tab focus without overriding a new owner: %s', (kind, test) => { + const controller = seededController() + controller.setExpanded(true) + const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]! + const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + controller.focusTab(first) + const h = mounted(controller) + const control = document.createElement('button') + document.body.append(control) + test.onTestFinished(() => { control.remove() }) + if (kind === 'origin-control') control.focus() + vi.spyOn(h.callbacks, 'onFocusTab').mockImplementation((id) => { + if (kind === 'new-control') control.focus() + if (kind === 'document' || kind === 'no-focus') { + vi.spyOn(document, 'activeElement', 'get').mockReturnValue(kind === 'document' ? document.documentElement : null) + } + controller.focusTab(id) + h.redraw() + }) + const focus = vi.spyOn(HTMLElement.prototype, 'focus') + fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`)) + if (kind === 'new-control') expect(document.activeElement).toBe(control) + else expect(focus).toHaveBeenCalledWith({ preventScroll: true }) +}) + +it('does not require a docked strip when a selected tab becomes floating in the same update', () => { + const controller = seededController() + controller.setExpanded(true) + const first = getPane(controller.getSnapshot().state, controller.getSnapshot().state.rootId).tabs[0]! + const second = controller.openContent({ kind: 'file', contentId: 'file:b', title: 'B' }) + controller.focusTab(first) + const h = mounted(controller) + vi.spyOn(h.callbacks, 'onFocusTab').mockImplementation((id) => { + controller.focusTab(id) + controller.floatTab(id) + h.redraw() + }) + fireEvent.click(h.node(`[data-dockkit-tab="${second}"]`)) + expect(h.node(`[data-dockkit-content="${second}"]`).hasAttribute('data-dockkit-float')).toBe(true) +}) + +it('renders an empty pane and rejects unsupported docked trees', () => { + const controller = new DockController() + controller.setExpanded(true) + const h = mounted(controller) + expect(h.node('[data-dockkit-empty]')).toBeDefined() + controller.openContent({ kind: 'file', contentId: 'file:a', title: 'A' }) + controller.splitPane() + const state = controller.getSnapshot().state + const root = getNode(state, state.rootId) + if (root.kind !== 'split') throw new Error('expected a split') + vi.spyOn(console, 'error').mockImplementation(() => {}) + const suppress = (event: ErrorEvent): void => { + if (event.error instanceof Error && event.error.message === 'DockLayout requires one pane or two horizontally split panes') { + event.preventDefault() + } + } + window.addEventListener('error', suppress) + try { + expect(() =>{ h.redraw({ state: { ...state, nodes: { ...state.nodes, [root.id]: { ...root, axis: 'column' } } } }) }) + .toThrow('two horizontally split panes') + } finally { + window.removeEventListener('error', suppress) + } +}) + +it('keeps a superseding gesture marker when an older follower detaches', () => { + const element = document.createElement('div') + const followers = { move: vi.fn(), up: vi.fn(), cancel: vi.fn() } + const first = followPointer(element, 1, followers) + const second = followPointer(element, 2, followers) + first() + expect(element.dataset.dockkitPointer).toBe('2') + second() + expect(element.dataset.dockkitPointer).toBeUndefined() +}) diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml index 3be8b2febf..0f1cb98168 100644 --- a/packages/client/ui-jobs/README.i18n.yaml +++ b/packages/client/ui-jobs/README.i18n.yaml @@ -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-jobs/README.md -README.md: b775bdd354dda43f7427de7878299e6a7d7e9d2d -README.zh.md: ef7ac07710393b9af8bb47d8763ef2c79f176d7d +README.md: b6c8f39d40ca4630313659a94adc305f4c379119 +README.zh.md: 871948194dfcc9a5f0a9ab1f795556944b19144f diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md index b775bdd354..b6c8f39d40 100644 --- a/packages/client/ui-jobs/README.md +++ b/packages/client/ui-jobs/README.md @@ -1,5 +1,5 @@ --- -description: "Web background-job surface: the session-header action listing the jobs this session can see; for users and maintainers of the background-job experience." +description: "The session-header background-job list: expandable streaming output panels, running/finished sections, and static rows for settled jobs without retained output." kind: "package-reference" --- @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package renders the background-job surface of the Web GUI: a session-header action that opens a popover listing the jobs this session can see. It reads host-computed registry state through the runtime's `jobsBySession` mirror and issues no RPC of its own. The trigger appears only when the session has at least one job, with a badge counting running and stopping jobs; settled rows stay visible and de-emphasized until the registry drops them. The model's own view of the same jobs belongs to `dsh-tool-jobs`; this package is a read-only projection for the human. +`dsh-client-ui-jobs` shows the session's background jobs in one header control, with lifecycle, elapsed time, progress, and terminal detail. Live jobs and settled jobs with retained output offer expandable output panels; collapsing stops the stream. Live rows lead with a ticking duration, followed by kind and status. Settled rows fold under a section heading; those without retained output, including subagents whose answers went to the model, stay static. ## Table of Contents @@ -25,11 +25,15 @@ This package renders the background-job surface of the Web GUI: a session-header ## Use this package -Mount this plugin alongside the runtime; the job action then appears in the session header whenever the session has at least one job. A click opens the popover: live rows first by start time, then settled rows by finish time, each showing the producer kind, label, status, and an elapsed duration that ticks once per second while live and freezes at completion. +Load the plugin through the web-app manifest; it renders nothing until the session can see at least one job, so an ordinary conversation never grows a control for a capability it is not using. -### Dismissal and limits +### One row per job -Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The list shows what one session can see through the wire view, so a job owned by another session never appears here; a process restart empties the list while the transcript keeps the `run_in_background` cards that started those jobs. +The `job.list` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join. + +### The expanded panel + +Expanding an observable row opens that job's output observation stream from `ctx.jobs` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream interruptions render as notices above the panel. ----- @@ -39,7 +43,13 @@ Escape closes the list and returns focus to the trigger, as does a pointer press
Implementation internals — click to expand -The package contributes one entry to `conversation.session.header.actions` (`JobListAction`), and the data arrives entirely through the `jobsBySession` list mirror that the Session Controller binding folds from `session/jobs` frames — no RPC, and no state beyond popover visibility. The badge counts `running` plus `stopping` and is omitted at zero. Rows are ordered with live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order; a settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours. Settled rows stay visible because a failed job's `detail` is the only place its failure is legible. The behavior is specified by the [Web background-job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md). +One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close. + +| File | Role | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | The job list: sections, durations, panels | +| [`src/client/index.ts`](src/client/index.ts) | Slot registration and the dictionaries | +| [`src/client/locales.ts`](src/client/locales.ts) | The `job` namespace copy (zh source of truth) |
@@ -48,19 +58,16 @@ The package contributes one entry to `conversation.session.header.actions` (`Job ## Further Exploration -Read these pages when the job surface is not enough. They move from the browser list to the registry and the model-facing tool. - -- [dsh-tool-jobs](../../jobs/tool-jobs/README.md) — the model-facing jobs tool over the same registry. -- [Session Controller](../../api/session-controller/README.md) — folds the `jobsBySession` mirror this package reads. -- [ui-subagent](../ui-subagent/README.md) — the subagent catalog, where a running one-shot background subagent also appears. -- [Web client architecture](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — how browser plugin rows load and register slots. +- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.list` and `job.follow` streams and the `ctx.jobs` service behind the rows and the panel. +- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the ring and projection semantics. +- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures. ----- ## Model Experience -None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. +None, as this package renders host-observed state and live output for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md). #### KV Cache effect @@ -70,11 +77,10 @@ None; the package never assembles or sends provider requests. +These limits define current package constraints, not a task backlog. -These limits define the current job list. They are current package constraints, not a general job-management comparison or a task backlog. - -- **Rows are read-only** — a job's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its job is still running. -- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a job owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those jobs. An unowned job (one started without a live `Agent`) reaches every session's list, matching what `list(caller)` reports to every caller. +- **No kill control** — cancellation stays with the model's `job_kill`. +- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up. ### Dev Note @@ -86,4 +92,4 @@ None. -**Runtime invariant:** No companion is published. This package is a read-only projection of the `jobsBySession` mirror onto one header slot entry. It emits no Cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. +**Runtime invariant:** No companion is published. This package is a read-only projection of the `ctx.jobs` rosters and views onto one header slot entry. It emits no Cordis events, owns no cross-plugin mutable state, and its single slot registration proves disposal through the HMR-safety spec. diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md index ef7ac07710..871948194d 100644 --- a/packages/client/ui-jobs/README.zh.md +++ b/packages/client/ui-jobs/README.zh.md @@ -1,5 +1,5 @@ --- -description: "Web 后台任务界面:列出本会话可见任务的会话头部动作;供后台任务体验的用户与维护者阅读。" +description: "会话头部后台任务列表:可展开的流式输出面板、进行中/已结束分组,以及无保留输出的已结束任务的静态行。" kind: "package-reference" --- @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包渲染 Web GUI 的后台任务界面:一个会话头部动作,打开后以弹层列出本会话可见的任务。它经运行时提供的 `jobsBySession` 镜像读取宿主计算的注册表状态,自身不发任何 RPC。触发器只在会话至少有一个任务时出现,角标计数运行中与停止中的任务;终态行保持可见并弱化,直到注册表把它们丢弃。模型对同一批任务的视角属于 `dsh-tool-jobs`;本包是给人类看的只读投影。 +`dsh-client-ui-jobs` 在一个头部控件中展示本会话的后台任务,包括生命周期、耗时、进度与终态详情。进行中的任务和保留了输出的已结束任务提供可展开的输出面板;收起即停流。进行中的行以持续更新的时长为主行,随后展示类型与状态。已结束的行折叠在分组标题下;没有保留输出的任务保持静态,包括回答已交给模型的 subagent。 ## 目录 @@ -25,11 +25,15 @@ kind: "package-reference" ## 使用本包 -与运行时一起挂载本插件;只要会话至少有一个任务,任务动作就会出现在会话头部。点击打开弹层:活跃行在前按开始时间升序,随后终态行按结束时间降序,每行显示生产者 kind、标签、状态,以及一个活跃时每秒跳动、完成后冻结的已耗时。 +通过 web-app 清单加载本插件;在会话看得到至少一个 job 之前它不渲染任何东西,因此普通对话不会为未使用的能力长出控件。 -### 关闭与边界 +### 一 job 一行 -Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。列表展示的是「一个会话通过协议视图能看到什么」,因此别的会话拥有的任务在这里永不出现;进程重启会清空列表,而 transcript(文本记录)里启动这些任务的 `run_in_background` 卡片仍在。 +`ctx.jobs` 镜像的 `job.list` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。 + +### 展开的面板 + +展开可观察的行会从 `ctx.jobs`(由 `dsh-api-job-controller` 安装)打开该 job 的输出观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。 ----- @@ -37,9 +41,15 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理 ## 理解实现
-实现细节——点击展开 +实现内幕——点击展开 -本包向 `conversation.session.header.actions` 贡献一个条目(`JobListAction`),数据完全来自会话控制器绑定从 `session/jobs` 帧折叠出的 `jobsBySession` 列表镜像——不发 RPC,除弹层开合外不持有任何状态。角标计数 `running` 加 `stopping`,为零时省略。行序为活跃行在前按 `startedAt` 升序、终态行按 `finishedAt` 降序,毫秒并列按启动顺序打破;缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位。终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 规定。 +头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。 + +| 文件 | 角色 | +|---|---| +| [`src/client/JobListAction.tsx`](src/client/JobListAction.tsx) | 任务列表:分组、时长、面板 | +| [`src/client/index.ts`](src/client/index.ts) | slot 注册与词典 | +| [`src/client/locales.ts`](src/client/locales.ts) | `job` 命名空间文案(zh 为事实源) |
@@ -48,42 +58,38 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理 ## 进一步探索 -当任务界面本身无法满足需要时,请阅读以下页面。它们从浏览器列表进入注册表与面向模型的工具。 - -- [dsh-tool-jobs](../../jobs/tool-jobs/README.zh.md)——同一注册表之上的面向模型任务工具。 -- [会话控制器](../../api/session-controller/README.zh.md)——折叠出本包读取的 `jobsBySession` 镜像。 -- [ui-subagent](../ui-subagent/README.zh.md)——subagent 目录,运行中的一次性后台 subagent 也会出现在那里。 -- [Web 客户端架构](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——浏览器插件行如何加载并注册 slot。 +- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行与面板背后的 `job.list`、`job.follow` 流与 `ctx.jobs` 服务。 +- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有环与投影语义的注册表契约。 +- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。 ----- ## 模型体验 -无,因为本包为人类渲染宿主计算出的注册表状态,不触及提示词、消息、schema、流或工具结果。 +无。本包为人类渲染宿主观测到的状态与实时输出,不触碰任何提示词、消息、schema、流或工具结果。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。 -#### KV Cache 影响 +#### KV 缓存影响 -无;本包从不组装或发送提供方请求。 +无;本包从不组装或发送 provider 请求。 ## 已知限制与延期工作 +这些限制界定当前包约束,不是任务清单。 -这些限制界定了当前任务列表。它们是当前包约束,不是通用任务管理对比或任务积压。 - -- **行是只读的**——任务的流式输出与人类发起的取消是各自独立的阶段。取消还额外欠一个 seam 没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 -- **列表不等于注册表自己的集合**——它展示的是一个会话通过协议视图能看到什么,因此别的会话拥有的任务在这里永不出现;进程重启会清空列表,而 transcript 里启动这些任务的 `run_in_background` 卡片仍在。无主任务(没有活体 `Agent` 时启动的)反过来会进入每个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 +- **没有 kill 控件**——取消仍归模型的 `job_kill`。 +- **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。 ### 开发备注
-维护者的工作上下文——点击展开 +维护者工作语境——点击展开 无。
-**运行时不变式:** 不发布伴生入口。本包只是将 `jobsBySession` 镜像以只读方式投影为一个会话头部 slot 条目,不发出 Cordis 事件,也不持有跨插件可变状态;其唯一的 slot 注册通过 HMR(热模块替换)安全性规范验证了资源释放行为。 +**运行时不变式:** 不发布伴生入口。本包只把 `ctx.jobs` 的名册与视图只读投影到一个 header slot,不发出 Cordis 事件,也不持有跨插件可变状态;其唯一的 slot 注册通过 HMR(热模块替换)安全性规范验证了资源释放行为。 diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json index e98eca744b..4a5d68d808 100644 --- a/packages/client/ui-jobs/package.json +++ b/packages/client/ui-jobs/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-jobs", - "description": "Session-header background-job list: live registry state mirrored from session/jobs frames", + "description": "Session-header background-job list with on-demand streaming record panels", "version": "0.1.6-alpha.2", "type": "module", "main": "lib/index.js", @@ -17,16 +17,6 @@ "./src/*": "./src/*", "./package.json": "./package.json" }, - "dsh": { - "client": { - "inject": [ - "@deepseek-ai/dsh-client-locale", - "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-primitives" - ], - "platform": "web" - } - }, "scripts": { "bundle": "tsdown", "watch": "tsdown --watch" @@ -44,21 +34,33 @@ "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-job-controller": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", - "@deepseek-ai/dsh-client-ui-slots": "workspace:^", - "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "workspace:^", - "react": "^18.2.0", - "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-ui-session": "workspace:^" + "@deepseek-ai/dsh-client-ui-session": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@types/react": "~18.3.1", + "react": "^18.2.0" }, "files": [ "lib/index.js", "lib/client.js", "lib/types/**/*.d.ts" - ] + ], + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-job-controller", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + } } diff --git a/packages/client/ui-jobs/src/client/JobListAction.module.css b/packages/client/ui-jobs/src/client/JobListAction.module.css index 06a78a4492..8d0cc4440c 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.module.css +++ b/packages/client/ui-jobs/src/client/JobListAction.module.css @@ -47,9 +47,9 @@ display: flex; flex-direction: column; gap: 1px; - width: 336px; - max-width: min(400px, calc(100vw - 32px)); - max-height: min(420px, calc(100vh - 140px)); + width: 440px; + max-width: min(520px, calc(100vw - 32px)); + max-height: min(480px, calc(100vh - 140px)); margin: 0; padding: 3px; overflow: auto; @@ -64,6 +64,27 @@ box-shadow: var(--dsw-elevation-prominent); } +/* Section labels over the live rows and the settled tail. */ +.sectionHeader { + margin: 4px 4px 1px; + padding: 4px 4px 3px; + border-top: 0.5px solid var(--dsw-alias-border-l1); + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +/* The leading section needs no divider above it. */ +.sectionHeader:first-child { + margin-top: 0; + border-top: 0; +} + +.item { + display: flex; + flex-direction: column; +} + .row { display: flex; align-items: center; @@ -72,20 +93,61 @@ width: 100%; min-height: 28px; padding: 4px 7px; + border: 0; border-radius: 7px; + background: transparent; color: var(--dsw-alias-label-primary); font-size: 12px; line-height: 17px; + text-align: left; + cursor: pointer; +} + +.row:hover, +.row:focus-visible { + background: var(--dsw-alias-fill-l1); +} + +/* A row with no observable output offers no interaction. */ +.rowStatic { + cursor: default; +} + +.rowStatic:hover { + background: transparent; } .rowSettled { color: var(--dsw-alias-label-tertiary); } -.rowDot { +/* Live rows align the dot with the first text line, not the row center. */ +.row .rowDot { flex: none; } +.main { + display: flex; + flex: 1; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.primary { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.secondary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} + .kind { flex: none; padding: 0 5px; @@ -105,23 +167,61 @@ text-overflow: ellipsis; } -.status, -.duration { +/* A failed task's detail is the producer's raw text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { flex: none; + max-width: 60%; + overflow: hidden; color: var(--dsw-alias-label-tertiary); font-size: 10px; line-height: 16px; -} - -/* A failed job's detail is the producer's raw error text, so it has no bound; - without this it widens the row past the menu instead of eliding like .label. */ -.status { - max-width: 40%; - overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .duration { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; font-variant-numeric: tabular-nums; } + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.chevronOpen { + transform: rotate(180deg); +} + +.panel { + margin: 0 8px 8px; + /* Long commands and output lines wrap in full instead of ellipsizing or + scrolling sideways, and the output region scrolls within a fixed height + instead of folding its middle lines. */ + --dsl-terminal-command-whitespace: pre-wrap; + --dsl-terminal-line-whitespace: pre-wrap; + --dsl-terminal-output-max-height: 288px; +} + +/* The row carries the state dot, so the panel omits its own and reclaims the + dot gutter down to an ordinary content inset. The override targets the card + element itself because the gutter variable is declared on it — a value + inherited from this container would lose to that declaration. */ +.panel :global([data-terminal]) { + --dsl-terminal-gutter: 12px; +} + +.notice { + margin: 0 8px 6px; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 16px; +} + +.noticeError { + color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary)); +} diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx index 7853ec4e2c..95b35d8f53 100644 --- a/packages/client/ui-jobs/src/client/JobListAction.tsx +++ b/packages/client/ui-jobs/src/client/JobListAction.tsx @@ -1,23 +1,66 @@ -import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' -import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' -import { IconChevronDownOutlineRegular, StateDot, useDismissOnOutsidePointer, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + IconChevronDownOutlineRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer, + type StateDotState, type TerminalBlockLabels, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' import { NS } from './locales.ts' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import css from './JobListAction.module.css' -/** Full props for the session-header background-job action. */ +/** Registration-side business face for the job list. */ +export interface JobListInjected { + hooks: { + /** Client jobs snapshot (rosters and observations) bound by the renderer as useJobs. */ + jobs: { + getSnapshot(): JobsSnapshot + subscribe(listener: () => void): () => void + } + } + /** + * Keep one session's roster current while the list is mounted; returns the + * stop function. Reference-counted by the client service. + */ + watchRows: (sessionId: SessionId) => () => void + /** + * Start observing one job's live output; returns the stop function. + * Reference-counted by the client service, so panels can overlap safely. + */ + observe: (sessionId: SessionId | undefined, id: JobView['id']) => () => void +} + +/** Full props for the session-header job-list action. */ export type JobListActionProps = - PropsRuntime<'conversation.session.header.actions'> & PropsLocale + PropsRuntime<'conversation.session.header.actions'> + & PropsLocale + & InjectFace /** Stable empty list so a session with no jobs keeps one array identity. */ -const NO_TASKS: readonly JobView[] = [] +const NO_JOBS: readonly JobView[] = [] + +/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */ +const VIEWPORT_MARGIN = 12 + -/** A job the registry still holds open, and whose duration therefore ticks. */ function isLive(job: JobView): boolean { return job.status === 'running' || job.status === 'stopping' } +/** + * Whether the row offers an output panel: every live job (its output may + * still arrive) and a settled one that left retained output behind. + */ +function isObservable(job: JobView): boolean { + return isLive(job) || job.output.total > 0 +} + +/** The one-line qualifier beside the status: live progress while running, the terminal reason once settled. */ +function jobDetail(job: JobView): string | undefined { + return job.progress ?? job.detail +} + /** Closed-union exhaustiveness fence for the wire status set. */ /* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ function assertNever(value: never): never { @@ -40,7 +83,6 @@ function dotState(status: JobView['status']): StateDotState { } } -/** Human status word for the row and its accessible name. */ function statusLabel(status: JobView['status'], t: TranslateNS): string { switch (status) { case 'running': return t('status.running') @@ -54,9 +96,9 @@ function statusLabel(status: JobView['status'], t: TranslateNS): stri } /** - * Elapsed time in at most two adjacent units. A background job that outlives - * an hour is already exceptional, so hours is the widest unit — beyond that the - * figure stays in hours rather than growing a day/month vocabulary no producer + * Elapsed time in at most two adjacent units. A job that outlives an hour is + * already exceptional, so hours is the widest unit — beyond that the figure + * stays in hours rather than growing a day/month vocabulary no producer * currently reaches. */ function formatDuration(elapsedMs: number, t: TranslateNS): string { @@ -69,8 +111,35 @@ function formatDuration(elapsedMs: number, t: TranslateNS): string { return t('duration.seconds', { seconds }) } +/** Localized display copy for the embedded terminal panel. */ +function terminalLabels(t: TranslateNS): TerminalBlockLabels { + return { + // The labels contract requires exit-fact formatters, but this panel never + // passes exit facts, so TerminalBlock never invokes them. + /* v8 ignore next */ + signal: signal => t('terminal.signal', { signal }), + /* v8 ignore next */ + exitCode: code => t('terminal.exitCode', { code }), + noExitCode: t('terminal.noExitCode'), + running: t('terminal.running'), + failed: t('terminal.failed'), + done: t('terminal.done'), + copy: t('terminal.copy'), + copied: t('terminal.copied'), + noOutput: t('terminal.noOutput'), + collapseAria: t('terminal.collapseAria'), + collapse: t('terminal.collapse'), + // The panel never caps lines (it scrolls), so the fold controls that + // would invoke these stay unrendered. + /* v8 ignore next */ + expandAria: hidden => t('terminal.expandAria', { n: hidden }), + /* v8 ignore next */ + expand: hidden => t('terminal.expand', { n: hidden }), + } +} + /** - * Live rows first in start order, then settled rows newest-first. Two jobs + * Live rows first in start order, then settled rows newest-first. Two rows * that settled in the same millisecond fall back to start order, so the sort * never depends on the host's map iteration. */ @@ -84,45 +153,202 @@ function ordered(jobs: readonly JobView[]): JobView[] { }) } +/** One job row plus, when observable and expanded, its live output panel. */ +function JobItem({ job, view, expanded, now, onToggle, t }: { + job: JobView + view: ObservedJob | undefined + expanded: boolean + /** Clock sample live rows derive their running duration from. */ + now: number + onToggle: () => void + t: TranslateNS +}) { + const live = isLive(job) + const status = statusLabel(job.status, t) + const detail = jobDetail(job) + const observable = isObservable(job) + const labels = useMemo(() => terminalLabels(t), [t]) + const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt + const duration = formatDuration(elapsed, t) + const durationCell = ( + + {duration} + + ) + const body = live + ? ( + <> + + + + {job.label} + {durationCell} + + + {job.kind} + {detail ?? status} + + + {/* A live row is always observable: its output may still arrive. */} + + + ) + : ( + <> + + {job.kind} + {job.label} + {detail ?? status} + {durationCell} + {observable ? : null} + + ) + return ( +
  • + {observable + ? ( + + ) + : ( + + {body} + + )} + {expanded && view !== undefined + ? ( +
    + {view.gapBefore ?
    {t('output.gap')}
    : null} + {view.error !== undefined + ?
    {t('output.error', { error: view.error })}
    + : null} + +
    + ) + : null} +
  • + ) +} + /** - * Session-header entry point for this session's background jobs. It renders - * nothing at all until the session has at least one job, so an ordinary - * conversation never grows a control for a capability it is not using. - * @param props - runtime slot currency plus the namespace translator. + * Session-header entry point for this session's background jobs. Mounting it + * keeps the session's roster stream open; it renders nothing at all until the + * session can see at least one job. Expanding an observable row (a live job, + * or a settled one with retained output) starts its observation stream, and + * collapsing (or closing the popover) stops it — output only flows while + * someone is watching. + * @param props - runtime slot currency, the jobs snapshot hook, the roster + * and observation controls, and the namespace translator. * @returns the trigger and its popover list, or null when there is nothing to show. */ -export function JobListAction({ sessionId, useSessions, t }: JobListActionProps) { - const jobs = useSessions(state => state.jobsBySession[sessionId]) ?? NO_TASKS +export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: JobListActionProps) { + const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS + const observedViews = useJobs(state => state.observed) const [open, setOpen] = useState(false) + const [expandedKey, setExpandedKey] = useState(undefined) const [now, setNow] = useState(() => Date.now()) const rootRef = useRef(null) const triggerRef = useRef(null) + const menuRef = useRef(null) + // Horizontal shift applied to the trigger-anchored popover so it stays + // inside the viewport (the stylesheet alone cannot see the anchor offset). + const [menuShift, setMenuShift] = useState(0) const rows = useMemo(() => ordered(jobs), [jobs]) - const liveCount = useMemo(() => jobs.filter(isLive).length, [jobs]) + const liveRows = useMemo(() => rows.filter(isLive), [rows]) + const settledRows = useMemo(() => rows.filter(job => !isLive(job)), [rows]) useDismissOnOutsidePointer(rootRef, open, setOpen) + // The roster follows the mounted session: one stream while this control + // lives, released with it. + useEffect(() => watchRows(sessionId), [sessionId, watchRows]) + // The clock only runs while an open list is showing something that moves. useEffect(() => { - if (!open || liveCount === 0) return + if (!open || liveRows.length === 0) return setNow(Date.now()) const timer = setInterval(() => { setNow(Date.now()) }, 1_000) return () => { clearInterval(timer) } - }, [open, liveCount]) + }, [open, liveRows.length]) - // The last job disappearing removes this control; close first so focus does - // not vanish from an unmounting node. + // Fit the open popover to the viewport: shift left when the anchored width + // would cross the right edge, never past the left margin. + useLayoutEffect(() => { + if (!open) { + setMenuShift(0) + return + } + const fit = (): void => { + const root = rootRef.current + const menu = menuRef.current + /* v8 ignore next -- both refs are attached while the open popover renders. */ + if (root === null || menu === null) return + const width = menu.offsetWidth + // Unlaid-out nodes (and jsdom) measure 0: keep the pure CSS anchor. + if (width === 0) return + const anchorLeft = root.getBoundingClientRect().left + setMenuShift(Math.max( + VIEWPORT_MARGIN - anchorLeft, + Math.min(0, window.innerWidth - VIEWPORT_MARGIN - width - anchorLeft), + )) + } + fit() + window.addEventListener('resize', fit) + return () => { window.removeEventListener('resize', fit) } + }, [open]) + + // Observation follows visibility: the stream opens when an observable panel + // expands and closes when it collapses, unmounts, or the popover closes. + const expandedRow = open && expandedKey !== undefined + ? rows.find(job => String(job.id) === expandedKey) + : undefined + const activeJob = expandedRow !== undefined && isObservable(expandedRow) ? expandedRow.id : undefined useEffect(() => { - if (jobs.length === 0 && open) setOpen(false) - }, [jobs.length, open]) + if (activeJob === undefined) return + return observe(sessionId, activeJob) + }, [sessionId, activeJob, observe]) - if (jobs.length === 0) return null + // The last job disappearing removes this control; close first so focus + // does not vanish from an unmounting node. + useEffect(() => { + if (rows.length === 0 && open) setOpen(false) + }, [rows.length, open]) - const countKey = liveCount > 0 - ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') - : (jobs.length === 1 ? 'count.idle.one' : 'count.idle.other') - const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : jobs.length }) + // An expanded row that left the list (owner disposal) folds its panel. + useEffect(() => { + if (expandedKey !== undefined && !rows.some(job => String(job.id) === expandedKey)) { + setExpandedKey(undefined) + } + }, [rows, expandedKey]) + + if (rows.length === 0) return null + + const countKey = liveRows.length > 0 + ? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other') + : (rows.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : rows.length }) const onKeyDown = (event: KeyboardEvent): void => { if (event.key !== 'Escape' || !open) return @@ -131,6 +357,20 @@ export function JobListAction({ sessionId, useSessions, t }: JobListActionProps) triggerRef.current?.focus() } + const item = (job: JobView) => ( + { + setExpandedKey(current => current === String(job.id) ? undefined : String(job.id)) + }} + t={t} + /> + ) + return (
    {open ? ( -
      - {rows.map((job) => { - const live = isLive(job) - const elapsed = live ? now - job.startedAt : (job.finishedAt ?? job.startedAt) - job.startedAt - const duration = formatDuration(elapsed, t) - const status = statusLabel(job.status, t) - return ( -
    • - - {job.kind} - {job.label} - {job.detail ?? status} - - {duration} - -
    • - ) - })} +
        + {liveRows.length > 0 + ? + : null} + {liveRows.map(item)} + {settledRows.length > 0 + ? + : null} + {settledRows.map(item)}
      ) : null} diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts index 22dc2c8425..4ee3c3b5de 100644 --- a/packages/client/ui-jobs/src/client/index.ts +++ b/packages/client/ui-jobs/src/client/index.ts @@ -1,11 +1,13 @@ /** * Background-job plugin, browser half: contributes one session-header action - * that renders this session's `ctx.jobs` records. The data arrives entirely - * through the `jobsBySession` list mirror, so the plugin issues no RPC and - * holds no state of its own beyond popover visibility. + * that renders this session's jobs. Job rows and per-row observation streams + * both arrive through the `jobs` client service; this plugin holds no + * transport state of its own. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' import { JobListAction } from './JobListAction.tsx' +import type { JobListInjected } from './JobListAction.tsx' +import type {} from '@deepseek-ai/dsh-api-job-controller/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' @@ -18,25 +20,31 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } } -export type { JobListActionProps } from './JobListAction.tsx' +export type { JobListActionProps, JobListInjected } from './JobListAction.tsx' -/** Required services for locale registration and header-slot contribution. */ -export const inject = ['sessions', 'slots', 'locale'] +/** Required services: the jobs rosters and observations, the slot registry, and dictionaries. */ +export const inject = ['jobs', 'slots', 'locale'] /** * Client plugin body: register the dictionaries and the header action. * @param ctx - client root context. */ export function apply(ctx: ClientContext): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-job: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-jobs: dictionaries') ctx.slots.inject( 'conversation.session.header.actions', () => ctx.slots.register({ name: 'conversation.session.header.actions', id: 'job-list', - // After the subagent catalog: session lineage reads before process work. + // Between the preset label and the subagent catalog (order 30): running + // work reads before the session lineage. order: 20, locale: NS, + inject: (): JobListInjected => ({ + hooks: { jobs: ctx.jobs.state }, + watchRows: sessionId => ctx.jobs.watchRows(sessionId), + observe: (sessionId, id) => ctx.jobs.observe(sessionId, id), + }), }, JobListAction), ) } diff --git a/packages/client/ui-jobs/src/client/locales.ts b/packages/client/ui-jobs/src/client/locales.ts index 70bb2e9e0f..1dc263aa19 100644 --- a/packages/client/ui-jobs/src/client/locales.ts +++ b/packages/client/ui-jobs/src/client/locales.ts @@ -10,6 +10,10 @@ export const zh = { 'count.idle.one': '{count} 个后台任务', 'count.idle.other': '{count} 个后台任务', 'list.aria': '后台任务', + 'section.live': '进行中', + 'section.settled': '已结束', + 'row.expandAria': '展开 {label} 的实时输出', + 'row.collapseAria': '收起 {label} 的实时输出', 'status.running': '运行中', 'status.stopping': '正在停止', 'status.completed': '已完成', @@ -20,6 +24,21 @@ export const zh = { 'duration.hours': '{hours}小时{minutes}分', 'duration.title.live': '已运行 {duration}', 'duration.title.done': '耗时 {duration}', + 'output.gap': '……较早的输出已丢弃……', + 'output.error': '实时输出流中断:{error}', + 'terminal.signal': '信号 {signal}', + 'terminal.exitCode': '退出码 {code}', + 'terminal.noExitCode': '未正常退出', + 'terminal.running': '运行中', + 'terminal.failed': '已失败', + 'terminal.done': '已完成', + 'terminal.copy': '复制', + 'terminal.copied': '已复制', + 'terminal.noOutput': '(无输出)', + 'terminal.collapse': '收起', + 'terminal.collapseAria': '收起输出', + 'terminal.expand': '展开其余 {n} 行', + 'terminal.expandAria': '展开被折叠的 {n} 行输出', } as const /** English dictionary, key-identical to the Chinese source of truth. */ @@ -29,6 +48,10 @@ export const en: Record = { 'count.idle.one': '{count} background job', 'count.idle.other': '{count} background jobs', 'list.aria': 'Background jobs', + 'section.live': 'Running', + 'section.settled': 'Finished', + 'row.expandAria': 'Show live output of {label}', + 'row.collapseAria': 'Hide live output of {label}', 'status.running': 'running', 'status.stopping': 'stopping', 'status.completed': 'completed', @@ -39,6 +62,21 @@ export const en: Record = { 'duration.hours': '{hours}h {minutes}m', 'duration.title.live': 'Running for {duration}', 'duration.title.done': 'Took {duration}', + 'output.gap': '… earlier output dropped …', + 'output.error': 'live output stream interrupted: {error}', + 'terminal.signal': 'signal {signal}', + 'terminal.exitCode': 'exit {code}', + 'terminal.noExitCode': 'no exit code', + 'terminal.running': 'running', + 'terminal.failed': 'failed', + 'terminal.done': 'done', + 'terminal.copy': 'Copy', + 'terminal.copied': 'Copied', + 'terminal.noOutput': '(no output)', + 'terminal.collapse': 'Collapse', + 'terminal.collapseAria': 'Collapse output', + 'terminal.expand': 'Show {n} more lines', + 'terminal.expandAria': 'Expand {n} collapsed output lines', } /** Key domain of the `job` namespace (zh is the source of truth). */ diff --git a/packages/client/ui-jobs/src/css-modules.d.ts b/packages/client/ui-jobs/src/css-modules.d.ts index bc5e482353..8811db1264 100644 --- a/packages/client/ui-jobs/src/css-modules.d.ts +++ b/packages/client/ui-jobs/src/css-modules.d.ts @@ -2,5 +2,3 @@ declare module '*.module.css' { const classes: Record export default classes } - -declare module '*.css' diff --git a/packages/client/ui-jobs/src/index.ts b/packages/client/ui-jobs/src/index.ts index 94d45c3ba1..457e7eeb9c 100644 --- a/packages/client/ui-jobs/src/index.ts +++ b/packages/client/ui-jobs/src/index.ts @@ -1,9 +1,9 @@ /** - * Background-job list plugin, node half. Pure UI plugin: the empty apply - * exists so the plugin appears in the host cordis.yml / Loader; the browser - * half ships via exports["./client"], discovered through the package.json - * dshClient declaration. + * Node half of the background-job list plugin. The browser half in + * `src/client/` owns every contribution; this entry exists so the package + * appears as an ordinary Loader row. + * @module @deepseek-ai/dsh-client-ui-jobs */ -/** Host plugin body — no host-side behavior for this source plugin. */ +/** Loader-visible no-op body; the browser half carries the feature. */ export function apply(): void {} diff --git a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts index ceee59fe1d..c78482198d 100644 --- a/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-jobs/tests/browser-plugin.client.spec.ts @@ -1,15 +1,18 @@ /** - * ui-job plugin halves: the browser entry's dictionary and header-slot + * ui-jobs plugin halves: the browser entry's dictionary and header-slot * registrations against the real SlotRegistry (with fiber teardown proving * removal — HMR safety), and the inert node entry. */ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { Context } from '@deepseek-ai/cordis' +import { JobId } from '@deepseek-ai/dsh-jobs/brand' +import { SessionId } from '@deepseek-ai/dsh-session/types' import { describe, expect, it } from 'vitest' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' import { stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' import { apply, inject } from '../src/client/index.ts' +import type { JobListInjected } from '../src/client/JobListAction.tsx' import { apply as applyNode } from '../src/index.ts' import { en, NS, zh } from '../src/client/locales.ts' @@ -20,6 +23,11 @@ function headerEntryIds(ctx: Context): (string | undefined)[] { .map(entry => entry.options.id) } +/** Observation requests handed to the stubbed jobs service. */ +const observed: [string | undefined, string][] = [] +/** Roster watches handed to the stubbed jobs service. */ +const watched: string[] = [] + /** Boot the browser half over a real slot tree that declares the header list. */ async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { const ctx = new Context() @@ -30,7 +38,17 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType null) - ctx.provide('sessions', {}) + ctx.provide('jobs', { + state: { getSnapshot: () => ({ rows: {}, observed: {} }), subscribe: () => () => {} }, + watchRows: (sessionId: string) => { + watched.push(sessionId) + return () => {} + }, + observe: (sessionId: string | undefined, id: string) => { + observed.push([sessionId, id]) + return () => {} + }, + } as never) // The locale plugin binds a settings scope, which reads the connection handle // and the forwarded-event port. ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) @@ -46,9 +64,26 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType { +describe('ui-jobs browser half', () => { it('declares the services it binds', () => { - expect(inject).toEqual(['sessions', 'slots', 'locale']) + expect(inject).toEqual(['jobs', 'slots', 'locale']) + }) + + it('exposes the jobs source and the roster and observation controls through the inject face', async () => { + const { ctx } = await bench() + const entry = ctx.slots + .entries('conversation.session.header.actions') + .find(candidate => candidate.options.id === 'job-list') + const inject = entry?.inject as (() => JobListInjected) | undefined + if (inject === undefined) throw new Error('job-list entry registered no inject face') + const face = inject() + expect(face.hooks.jobs).toBeDefined() + const release = face.watchRows(SessionId('session')) + expect(watched).toEqual(['session']) + release() + const stopper = face.observe(SessionId('session'), JobId('bash-1')) + expect(observed).toEqual([['session', 'bash-1']]) + stopper() }) it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { @@ -75,7 +110,7 @@ describe('ui-job browser half', () => { }) }) -describe('ui-job node half', () => { +describe('ui-jobs node half', () => { it('contributes no host behavior', () => { // The node half exists only so the plugin appears in the Loader tree. expect(applyNode).not.toThrow() diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx index e42e0ef556..dd50c639c6 100644 --- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx +++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx @@ -1,241 +1,370 @@ // @vitest-environment jsdom -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client' -import type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' +import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx' import { zh } from '../src/client/locales.ts' -// Live rows render `now - startedAt`, so every assertion needs a pinned clock. -beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(START) -}) - afterEach(() => { cleanup() - vi.useRealTimers() vi.restoreAllMocks() + vi.useRealTimers() }) const SESSION = 'session' as SessionId -const START = 1_700_000_000_000 const t: JobListActionProps['t'] = makeTranslate(zh) +/** A running job with nothing retained yet. */ function job(over: Partial = {}): JobView { return { id: 'bash-1' as JobView['id'], kind: 'bash', label: 'pnpm run build', status: 'running', - startedAt: START, + startedAt: 1_700_000_000_000, + output: { total: 0, earliest: 0 }, ...over, } } -function props(jobs: readonly JobView[] | undefined): JobListActionProps { - const state = { - ids: [SESSION], - byId: {}, - phase: 'ready', - projectionsBySession: {}, - jobsBySession: jobs === undefined ? {} : { [SESSION]: jobs }, - } satisfies SessionListState - function useSessions(select: (snapshot: SessionListState) => T): T { - return select(state) - } - return { sessionId: SESSION, useSessions, t } as unknown as JobListActionProps +/** A job that left retained output behind, so its row offers a panel even once settled. */ +function outputJob(over: Partial = {}): JobView { + return job({ output: { total: 12, earliest: 0 }, ...over }) } -/** - * Rows in render order as `[kind, label, status, duration]`. Adjacent spans - * carry no whitespace between them, so the cells are read one element at a - * time rather than split out of a flattened string. - */ -function rowCells(): string[][] { - return within(screen.getByRole('list', { name: zh['list.aria'] })) - .getAllByRole('listitem') - .map(row => [...row.children] - .map(cell => cell.textContent ?? '') - .filter(text => text !== '')) +function props( + jobs: readonly JobView[], + observe: JobListActionProps['observe'] = () => () => {}, + observed: Readonly> = {}, + watchRows: JobListActionProps['watchRows'] = () => () => {}, +): JobListActionProps { + const jobsState: JobsSnapshot = { rows: jobs.length > 0 ? { [SESSION]: jobs } : {}, observed } + function useJobs(select: (value: JobsSnapshot) => T): T { + return select(jobsState) + } + return { sessionId: SESSION, useJobs, watchRows, observe, t } as JobListActionProps +} + +function openList(): void { + fireEvent.click(screen.getAllByRole('button')[0]!) } describe('JobListAction visibility', () => { - it('renders nothing while the session has no jobs', () => { - const { container } = render() + it('renders nothing while the session sees no jobs', () => { + const { container } = render() expect(container.innerHTML).toBe('') }) - it('counts only live jobs, and falls back to the total when none are live', () => { - const { rerender } = render() + it('watches the session roster while mounted and releases it on unmount', () => { + const stop = vi.fn() + const watchRows = vi.fn(() => stop) + const { unmount } = render() + expect(watchRows).toHaveBeenCalledWith(SESSION) + expect(stop).not.toHaveBeenCalled() + unmount() + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('counts live jobs on the trigger', () => { + render() expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() - - rerender() - expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() }) - it('closes and unmounts when the last job disappears while the list is open', () => { - const { container, rerender } = render() - fireEvent.click(screen.getByRole('button')) - expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() - - rerender() - expect(container.innerHTML).toBe('') + it('falls back to the total when nothing is live', () => { + render() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() }) }) describe('JobListAction rows', () => { - it('orders live jobs by start, then settled jobs newest-first', () => { + it('renders a settled job without retained output as a static row', () => { render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()).toEqual([ - ['bash', 'earlier live', '运行中', '0秒'], - ['bash', 'later live', '运行中', '0秒'], - ['bash', 'new done', '已失败', '9秒'], - ['bash', 'old done', '已完成', '1秒'], + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() + openList() + expect(screen.getByText('explore the repo')).toBeDefined() + expect(screen.queryByRole('button', { name: zh['row.expandAria'].replace('{label}', 'explore the repo') })).toBeNull() + }) + + it('offers a panel on a live row before any output arrived', () => { + render() + openList() + expect(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'warming up') })).toBeDefined() + }) + + it('shows the live progress line in place of the status word', () => { + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + expect(within(list).getAllByRole('listitem')).toHaveLength(1) + expect(within(list).getByText('pnpm run build')).toBeDefined() + expect(within(list).getByText('winding down')).toBeDefined() + }) + + it('labels each non-empty section and drops the heading of an empty one', () => { + const settled = [job({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 1_700_000_012_000 })] + const { rerender } = render() + openList() + expect(screen.getByText(zh['section.live'])).toBeDefined() + expect(screen.getByText(zh['section.settled'])).toBeDefined() + // A live-only list keeps its own heading and drops the settled one. + rerender() + expect(screen.getByText(zh['section.live'])).toBeDefined() + expect(screen.queryByText(zh['section.settled'])).toBeNull() + }) + + it('shows a settled duration and ticks a live one', () => { + vi.useFakeTimers({ now: 1_700_000_020_000 }) + render() + openList() + expect(screen.getByText('5秒')).toBeDefined() + expect(screen.getByText('12秒')).toBeDefined() + expect(screen.getByText('1小时3分')).toBeDefined() + expect(screen.getByText('1分12秒')).toBeDefined() + act(() => { vi.advanceTimersByTime(1_000) }) + expect(screen.getByText('6秒')).toBeDefined() + }) + + it('lists rows with kind, label, and progress, terminal detail, or status', () => { + render() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + const items = within(list).getAllByRole('listitem') + expect(items).toHaveLength(2) + expect(items[0]?.textContent).toContain('pnpm run build') + expect(items[0]?.textContent).toContain(zh['status.running']) + expect(items[1]?.textContent).toContain('exit code: 3') + }) + + it('orders live rows first by start and settled rows newest-first with tie-breaks', () => { + const settled = (id: string, label: string, startedAt: number, finishedAt?: number): JobView => + job({ + id: id as JobView['id'], + label, + status: 'completed', + startedAt, + ...finishedAt !== undefined ? { finishedAt } : {}, + }) + render() + openList() + const labels = within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(item => item.querySelector('[title]')?.getAttribute('title')) + expect(labels).toEqual([ + 'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e', ]) + expect(screen.getAllByText(zh['status.completed']).length).toBeGreaterThan(0) }) it('breaks a settled tie on start order so map iteration never decides it', () => { render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) + openList() + expect(within(screen.getByRole('list')).getAllByRole('listitem').map(row => row.querySelector('[title]')?.getAttribute('title'))) + .toEqual(['first', 'second']) }) it('prefers the producer detail over the generic status word', () => { render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()[0]).toContain('signal: SIGTERM') + openList() + expect(within(screen.getByRole('list')).getAllByRole('listitem')[0]?.textContent).toContain('signal: SIGTERM') }) it('renders every status word, including the stopping transition', () => { render() - fireEvent.click(screen.getByRole('button')) - const words = rowCells().map(cells => cells[2]) - expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) - expect([...screen.getByRole('list').querySelectorAll(':scope > li [data-state]')].map(node => node.getAttribute('data-state'))) + openList() + for (const word of ['运行中', '正在停止', '已完成', '已取消', '已失败']) { + expect(within(screen.getByRole('list')).getByText(word)).toBeDefined() + } + expect([...screen.getByRole('list').querySelectorAll('li [data-state]')].map(node => node.getAttribute('data-state'))) .toEqual(['ongoing', 'warning', 'done', 'warning', 'error']) }) }) -describe('JobListAction duration', () => { - it('advances a live row once per second and freezes a settled one', () => { - vi.setSystemTime(START + 1_000) - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells()[0]).toContain('1秒') - expect(rowCells()[1]).toContain('4秒') - - act(() => { vi.advanceTimersByTime(2_000) }) - expect(rowCells()[0]).toContain('3秒') - expect(rowCells()[1]).toContain('4秒') +describe('JobListAction observation', () => { + it('marks killed rows, renders every output line unfolded, and copies the command', async () => { + const writeText = vi.fn().mockResolvedValue(undefined) + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) + const lines = Array.from({ length: 24 }, (_value, index) => `line ${index + 1}`).join('\n') + const view: ObservedJob = { + jobId: 'bash-1' as JobView['id'], + text: lines, + gapBefore: false, + streaming: false, + } + render() + openList() + expect(screen.getByText(zh['status.killed'])).toBeDefined() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + // The panel scrolls instead of folding: every line renders, no fold control. + expect(screen.getByText('line 1')).toBeDefined() + expect(screen.getByText('line 24')).toBeDefined() + expect(screen.queryByRole('button', { name: zh['terminal.expandAria'].replace('{n}', '8') })).toBeNull() + // The panel draws no state dot or label of its own — the row carries it. + expect(screen.queryByText(zh['terminal.done'])).toBeNull() + // The copy control carries the command, not the output. + await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['terminal.copy'] })) }) + expect(writeText).toHaveBeenCalledWith('pnpm run build') }) - it('widens to minutes and then hours, and never shows a negative figure', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) + it('shifts an overflowing popover back inside the viewport and follows resizes', () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(440) + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 344 } as DOMRect) + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) + try { + render() + openList() + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + // 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin. + expect(menu.style.left).toBe('-96px') + + window.innerWidth = 900 + fireEvent(window, new Event('resize')) + // 900 - 12 - 440 - 344 = 104 > 0: the anchored position fits again. + expect(menu.style.left).toBe('0px') + } finally { + Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) + } }) - it('runs no clock while the list is closed', () => { - const interval = vi.spyOn(globalThis, 'setInterval') - render() - expect(interval).not.toHaveBeenCalled() - fireEvent.click(screen.getByRole('button')) - expect(interval).toHaveBeenCalledTimes(1) + it('never crosses the left viewport margin for an oversized popover', () => { + vi.spyOn(HTMLElement.prototype, 'offsetWidth', 'get').mockReturnValue(800) + vi.spyOn(HTMLDivElement.prototype, 'getBoundingClientRect').mockReturnValue({ left: 4 } as DOMRect) + const originalWidth = window.innerWidth + Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true }) + try { + render() + openList() + const menu = screen.getByRole('list', { name: zh['list.aria'] }) + // max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin. + expect(menu.style.left).toBe('8px') + } finally { + Object.defineProperty(window, 'innerWidth', { value: originalWidth, configurable: true, writable: true }) + } }) - it('runs no clock for an open list holding only settled jobs', () => { - const interval = vi.spyOn(globalThis, 'setInterval') - render() - fireEvent.click(screen.getByRole('button')) - expect(interval).not.toHaveBeenCalled() + it('ignores other keys while open and closes once the roster empties', () => { + const settledPair = [ + outputJob({ id: 'bash-1' as JobView['id'], status: 'completed', finishedAt: 2 }), + outputJob({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 3 }), + ] + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个后台任务' })).toBeDefined() + openList() + const list = screen.getByRole('list', { name: zh['list.aria'] }) + fireEvent.keyDown(list, { key: 'a' }) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + // The roster emptying unmounts the control entirely; the open flag resets first. + rerender() + expect(screen.queryByRole('button')).toBeNull() }) -}) -describe('JobListAction dismissal', () => { - it('closes on Escape and returns focus to the trigger', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - expect(trigger.getAttribute('aria-expanded')).toBe('true') + it('starts observing on expand with the session identity, renders the live text, and stops on collapse', () => { + const stop = vi.fn() + const observe = vi.fn(() => stop) + const view: ObservedJob = { + jobId: 'bash-1' as JobView['id'], + text: 'compiling…\n', + gapBefore: false, + streaming: true, + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1') + expect(screen.getByText('compiling…')).toBeDefined() - fireEvent.keyDown(trigger, { key: 'Escape' }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - expect(document.activeElement).toBe(trigger) + fireEvent.click(screen.getByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'pnpm run build') })) + expect(stop).toHaveBeenCalledTimes(1) }) - it('ignores other keys and a closed-list Escape', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.keyDown(trigger, { key: 'Escape' }) - expect(trigger.getAttribute('aria-expanded')).toBe('false') - - fireEvent.click(trigger) - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - expect(trigger.getAttribute('aria-expanded')).toBe('true') + it('renders a running panel with the prompt alone for a live row whose output has not arrived', () => { + const view: ObservedJob = { + jobId: 'bash-1' as JobView['id'], + text: '', + gapBefore: false, + streaming: true, + } + const { container } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(container.querySelector('[data-running]')).not.toBeNull() + expect(screen.queryByText(zh['terminal.noOutput'])).toBeNull() }) - - it('closes on an outside pointer press but not on one inside', () => { - render() - const trigger = screen.getByRole('button') - fireEvent.click(trigger) - - fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) - expect(trigger.getAttribute('aria-expanded')).toBe('true') - fireEvent.pointerDown(document.body) - expect(trigger.getAttribute('aria-expanded')).toBe('false') + it('surfaces retention gaps and stream failures above the panel', () => { + const view: ObservedJob = { + jobId: 'bash-1' as JobView['id'], + text: 'tail only', + gapBefore: true, + streaming: false, + error: 'connection lost', + } + render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + expect(screen.getByText(zh['output.gap'])).toBeDefined() + expect(screen.getByText('实时输出流中断:connection lost')).toBeDefined() }) -}) -describe('JobListAction wire tolerance', () => { - it('treats a settled job with no finishedAt as zero-duration and sorts it by start', () => { - // `finishedAt` is optional on the wire; the Host always sets it, so this - // covers a producer or carrier that ever stops doing so. - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ - ['finished', '3秒'], - ['no finish', '0秒'], - ]) + it('stops observation when the popover closes via Escape', () => { + const stop = vi.fn() + render( stop)} />) + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' }) + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('list', { name: zh['list.aria'] })).toBeNull() }) - it('falls back to start order when neither settled job carries a finish time', () => { - render() - fireEvent.click(screen.getByRole('button')) - expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) + it('folds an expanded panel whose row left the roster', () => { + const stop = vi.fn() + // One stable observe identity across renders, as the inject face provides. + const observe = () => stop + const { rerender } = render() + openList() + fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') })) + rerender() + expect(stop).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull() }) }) diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json index 0e66aa4958..38756e4d88 100644 --- a/packages/client/ui-jobs/tsconfig.json +++ b/packages/client/ui-jobs/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/job-controller/tsconfig.client.json" + }, { "path": "../../api/session-controller/tsconfig.client.json" }, diff --git a/packages/client/ui-layout/tests/app-frame.client.spec.tsx b/packages/client/ui-layout/tests/app-frame.client.spec.tsx index b097c4b62c..7e334afb80 100644 --- a/packages/client/ui-layout/tests/app-frame.client.spec.tsx +++ b/packages/client/ui-layout/tests/app-frame.client.spec.tsx @@ -78,7 +78,6 @@ function mountFrame(windowWidth = frameWidth) { }, phase: 'ready', projectionsBySession: {}, - jobsBySession: {}, }) const workspaceState: WorkspaceSnapshot = { items: [], archivedSessionIds: [], pinnedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-layout/tests/document-title.client.spec.tsx b/packages/client/ui-layout/tests/document-title.client.spec.tsx index 0aefd57215..0b39340f74 100644 --- a/packages/client/ui-layout/tests/document-title.client.spec.tsx +++ b/packages/client/ui-layout/tests/document-title.client.spec.tsx @@ -21,7 +21,6 @@ function titleSources() { byId: { [sessionId]: { id: sessionId, displayTitle: 'Test', running: false, retainedBy: { mainView: 1 }, blank: false, updatedAt: 1 } }, phase: 'ready', projectionsBySession: {}, - jobsBySession: {}, }) const panelInfo = createSnapshotStore({ activePanelId: null }) return { diff --git a/packages/client/ui-open-in-app/README.i18n.yaml b/packages/client/ui-open-in-app/README.i18n.yaml index fbf2a75bc6..b0fa75db3e 100644 --- a/packages/client/ui-open-in-app/README.i18n.yaml +++ b/packages/client/ui-open-in-app/README.i18n.yaml @@ -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-open-in-app/README.md -README.md: b70b39e79a172badc2b237083d6cd268e9cce06e -README.zh.md: 9d47e57cfe388b3823cee648f1fdf3187b534d78 +README.md: 138be128c291fc4a0611295c54768a95f33f3ae1 +README.zh.md: b81f62f9dbf8ccfbc1822b8a72df95e7169228c0 diff --git a/packages/client/ui-open-in-app/README.md b/packages/client/ui-open-in-app/README.md index b70b39e79a..138be128c2 100644 --- a/packages/client/ui-open-in-app/README.md +++ b/packages/client/ui-open-in-app/README.md @@ -29,9 +29,11 @@ Mount this plugin in the Web composition beside [`dsh-host-open-in-app`](../../h ### What to expect -The main button shows the remembered application's icon — the real application icon wherever the host extracts one (macOS bundle icons, Windows executable icons, Linux theme icons), a generic glyph where it serves none — and a design-system tooltip ("Open locally"); clicking launches immediately. The chevron opens a dense menu of the installed applications with the remembered one marked by a filled row. Availability is read once per page from the host; the last chosen application persists in the browser (`dsh.open-in-app.choice`), and a choice that is no longer installed falls back to the first available entry. A launch that finishes quickly leaves the button untouched — the dimmed busy treatment appears only after 250 ms in flight — and a failed launch shows the error tooltip and a red outline for two seconds. All copy lives in the bilingual `open-in-app` locale namespace; an application id the dictionaries cannot name is not offered. +The Session header and document header use the same 24px-high split button with 9px corners. Both headers show only the icon; their tooltip names the default application or the reveal action. Both show the default action’s icon, mark its application with “(default)” in the menu, disable while their own gesture runs, and report failures through a transient toast. The directory adapter uses the existing cross-platform application catalog and remembers the last successful choice in `dsh.open-in-app.choice`; a missing choice falls back to the first available application. -In the document preview, the header's "Open ▾" split button opens the previewed file in the Host's default application from its main button, and its menu offers that action and "Show file location" (the file manager's reveal). A file the preview cannot render, whether an unsupported suffix such as video or an archive or a file the reader rejected as non-text or oversized, shows an "Open in default app" button in its empty state where Retry would otherwise stand. Both controls hand the Host the absolute path the file's metadata reports, appear only after the Host answered that a desktop is available, disable only while their own gesture settles, and announce a failed gesture once through a transient toast ("Could not open. Try again." or "Could not show the file location. Try again."); nothing stays on the control afterwards. +File menus list the discovered associated applications without a separate “Open in default app” row. “Show file location” stays in a fixed footer separated from the scrolling applications. A successful query with only one available action renders a single button without a dropdown. Initial file-association loading uses a gray skeleton icon. When no default application is identified, that row reads “Show file location (default)” and the main button reveals the file. Directory menus have no reveal row. Choosing a file application does not change the system default. An unpreviewable file uses the same menu in a 40px-high split button with an icon and action text; its label is “Open” or “Show file location” according to the default action. + +Mounted controls with the same association reader and file share one query and its results; opening either menu refreshes all of them. Releasing the last control cancels the query and discards its state. Cancelled queries cannot replace another file’s results. A failed query displays a menu message and leaves reveal available as the default action. File-association discovery follows [native-command](../../util/native-command/README.md); an empty result, including a platform without a discovery adapter, uses the same reveal fallback without platform branches in the control. ----- @@ -43,7 +45,7 @@ In the document preview, the header's "Open ▾" split button opens the previewe The plugin registers the split button on `conversation.session.header.utilities` through the standard slot/inject currency and registers the `open-in-app` dictionaries as one effect. A page-lifetime controller ([`src/client/controller.ts`](src/client/controller.ts)) owns the once-per-page availability read, the persisted choice snapshot store, and the launch POST; the component receives both stores through the inject `hooks` compartment, so every Session header shares one truth. Document-relative route forms and wire payload types come from the host package's browser-safe `@deepseek-ai/dsh-host-open-in-app/shared` subpath. In-flight launches are guarded by a ref — repeat clicks and menu picks during a launch are ignored whole (a pick would otherwise persist a choice the gesture never opened) — and the busy/error dress is timer-driven around the `launch` promise. -The file controls register on the document preview's `sidebar.right.tab.document.actions` and `sidebar.right.tab.document.unpreviewable` child slots, whose owner props carry the file’s absolute execution-environment path. A second page-lifetime controller ([`src/client/open-path.ts`](src/client/open-path.ts)) reads the Session Remote's `session.canOpenWorkspacePath` once per page into a desktop snapshot store both controls share, and runs each gesture through `session.openWorkspacePath` with the path and, for reveal, `action: 'reveal'`; a refused or rejected call resolves as the failure kind the control announces. The controls share one gesture hook that owns the pending flag and the toast, so a gesture from one control never restyles the other. The node half is an empty `apply` that keeps the plugin on the host roster. +The directory and file adapters supply application metadata and operations to [`OpenTargetButton`](src/client/OpenTargetButton.tsx), which owns menu ordering, default markers, icons, sizing, and gesture feedback. The file header and empty state share `FileOpenTarget`, while `OpenPathInjected.applications` queries `session.workspacePathApplications` through [`open-path.ts`](src/client/open-path.ts). Opening uses `session.openWorkspacePath`; the Host revalidates an explicitly selected handler before launch. The directory adapter keeps the existing catalog routes. `FileRouteAction` supplies the same control to delivery cards and change review through `deliverables.file.actions` and `deliverables.review.file.actions`; their authenticated routes retain Session file authorization. A failed or unavailable file query therefore needs no platform-specific UI implementation. @@ -90,4 +92,4 @@ The feature-level decisions, including the split into the host package and this -**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and three slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. +**Runtime invariant:** No companion is published. The plugin registers one dictionary effect and five slot entries whose disposal the HMR-safety spec proves; application availability, the choice, and the desktop answer live in the controllers' snapshot stores with no second copy to diverge. diff --git a/packages/client/ui-open-in-app/README.zh.md b/packages/client/ui-open-in-app/README.zh.md index 9d47e57cfe..b81f62f9db 100644 --- a/packages/client/ui-open-in-app/README.zh.md +++ b/packages/client/ui-open-in-app/README.zh.md @@ -29,9 +29,11 @@ kind: "package-reference" ### 预期行为 -主按钮显示记住的应用图标——凡主机能提取的都是应用真实图标(macOS bundle 图标、Windows 可执行文件图标、Linux 主题图标),提取不到时是通用占位图形——并带设计系统 tooltip(「在本地打开」);点击立即启动。下拉箭头打开已安装应用的紧凑菜单,记住的条目以整行填充标记。可用性每页读取一次;上次选择的应用持久化在浏览器中(`dsh.open-in-app.choice`),不再安装的选择回退到第一个可用条目。快速完成的启动不改变按钮外观——变暗的等待态只在飞行超过 250 毫秒后出现——失败的启动显示错误 tooltip 与红色描边两秒。所有文案在双语 `open-in-app` locale 命名空间中;词典无法命名的应用 id 不会被提供。 +会话标题栏和文档标题栏共用同一个高 24px、圆角 9px 的分体按钮。两个标题栏都只显示图标,悬停提示显示默认应用名称或文件定位动作。两者都显示默认动作的图标,在菜单的默认应用后标注“(默认)”,仅在自己的操作执行期间禁用,并通过短暂提示报告失败。目录适配器使用已有的跨平台应用列表,将最后一次成功选择保存在 `dsh.open-in-app.choice` 中;原选择不可用时回退到第一个可用应用。 -在文档预览里,头部的「打开 ▾」分体按钮的主按钮用 Host 默认应用打开当前文件,菜单提供同一动作和「显示文件位置」(文件管理器的定位)。预览无法渲染的文件,无论是视频、压缩包这类不支持的后缀,还是被读取器判为非文本或过大的文件,都会在空态里原本放重试的位置显示「用默认应用打开」按钮。两个控件都把文件元数据报出的绝对路径交给 Host,只在 Host 回答有桌面之后出现,只在自己的手势结算期间禁用,失败时通过短暂 toast 提示一次(「打开失败,请重试」或「无法显示文件位置,请重试」),之后控件上不残留任何状态。 +文件菜单列出已发现的关联应用,不单列“用默认应用打开”。“显示文件位置”固定在菜单底部,通过分隔线与滚动的应用列表分开。查询成功且只有一个可用操作时显示单按钮,不再显示下拉箭头。文件关联首次加载时使用灰色骨架图标。未识别到默认应用时,该项显示为“显示文件位置(默认)”,主按钮也执行文件定位。目录菜单不包含定位项。选择文件应用不修改系统默认应用。无法预览文件时,空态使用同一菜单,按钮增大到 40px 高并显示图标和动作文字;根据默认动作显示“打开”或“显示文件位置”。 + +使用同一查询函数和文件的已挂载控件共用查询与结果,打开任一菜单会刷新所有相关控件。最后一个控件释放后取消查询并清除状态。已取消的查询不会覆盖其他文件的结果。查询失败时菜单显示提示,并以文件定位作为默认动作。文件关联查询的平台支持范围见 [native-command](../../util/native-command/README.zh.md);查询结果为空时,包括平台尚无查询适配器的情况,控件统一使用定位动作,不按操作系统分支处理。 ----- @@ -43,7 +45,7 @@ kind: "package-reference" 插件通过标准 slot/inject 机制把分体按钮注册到 `conversation.session.header.utilities`,并以一个 effect 注册 `open-in-app` 词典。一个页面生命周期的 controller([`src/client/controller.ts`](src/client/controller.ts))拥有每页一次的可用性读取、持久化选择的 snapshot store 与启动 POST;组件经 inject 的 `hooks` 隔间接收两个 store,因此所有会话头部共享同一份事实。文档相对的路由形式与 wire 载荷类型来自主机包的浏览器安全子路径 `@deepseek-ai/dsh-host-open-in-app/shared`。飞行中的启动由 ref 守卫——启动期间的重复点击与菜单选择被整体忽略(否则会持久化一个该手势从未打开的选择)——busy/error 视觉由围绕 `launch` promise 的定时器驱动。 -文件控件注册在文档预览的 `sidebar.right.tab.document.actions` 与 `sidebar.right.tab.document.unpreviewable` 子 slot 上,owner props 携带文件在执行环境中的绝对路径。第二个页面生命周期的 controller([`src/client/open-path.ts`](src/client/open-path.ts))每页读取一次 Session Remote 的 `session.canOpenWorkspacePath`,写进两个控件共用的桌面 snapshot store,并把每个手势交给 `session.openWorkspacePath`,带上路径,定位时再带 `action: 'reveal'`;被拒绝或抛错的调用解析为控件要提示的失败种类。两个控件共用一个手势 hook,由它持有 pending 标志和 toast,所以一个控件的手势不会改变另一个控件的样子。节点半边是一个空 `apply`,让插件出现在主机侧的插件名册上。 +目录和文件适配器把应用信息与操作交给 [`OpenTargetButton`](src/client/OpenTargetButton.tsx),由它统一管理菜单顺序、默认标记、图标、尺寸和操作反馈。文件标题栏和空态共用 `FileOpenTarget`,`OpenPathInjected.applications` 通过 [`open-path.ts`](src/client/open-path.ts) 查询 `session.workspacePathApplications`。打开操作使用 `session.openWorkspacePath`,Host 在启动前重新验证指定的关联应用。`FileRouteAction` 通过 `deliverables.file.actions` 和 `deliverables.review.file.actions` 为交付卡片和变更对比页提供同一控件,其认证路由保留会话文件校验。目录适配器继续使用已有的应用列表路由,文件查询失败或不可用时无需增加平台专用的界面实现。 @@ -90,4 +92,4 @@ Host 在打开或定位前通过当前文件系统验证路径。没有对应 Ho -**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和三个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 +**运行时不变式:** 不发布伴生入口。插件注册一个词典 effect 和五个 slot 条目,HMR 安全性 spec 证明它们都会在资源释放时撤销;应用可用性、选择与桌面回答存储在控制器的快照存储中,不存在可能与之分歧的第二份副本。 diff --git a/packages/client/ui-open-in-app/package.json b/packages/client/ui-open-in-app/package.json index 3b9e784afb..fde52fc3e9 100644 --- a/packages/client/ui-open-in-app/package.json +++ b/packages/client/ui-open-in-app/package.json @@ -70,6 +70,8 @@ "@deepseek-ai/dsh-host-open-in-app": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@types/react": "~18.3.1", - "react": "^18.2.0" + "react": "^18.2.0", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", + "@deepseek-ai/dsh-native-command": "workspace:^" } } diff --git a/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx new file mode 100644 index 0000000000..49c27245f1 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/FileRouteAction.tsx @@ -0,0 +1,37 @@ +/** Adapt authorized delivery and changed-file routes to the shared opening control. */ +import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-deliverables/client' +import { parseNativeFileApplications } from '@deepseek-ai/dsh-native-command/types' +import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' +import { useFileApplications } from './file-applications.ts' +import { OpenTargetButton } from './OpenTargetButton.tsx' +import type { NS } from './locales.ts' + +async function queryRoute(url: string, signal: AbortSignal): Promise { + try { + const response = await fetch(url, { signal }) + if (!response.ok) return null + return parseNativeFileApplications(await response.json()) + } catch (_error) { + // Unavailable routes and malformed responses leave file reveal as the fallback. + return null + } +} + +/** + * Render file actions without bypassing the owning Session's authorization route. + * @param props - authenticated route, desktop availability, and native gesture callback. + * @returns the shared compact control, or null without a desktop. + */ +export function FileRouteAction( + props: Pick, 'actionUrl' | 'available' | 'pending' | 'onAction'> & PropsLocale, +) { + const association = useFileApplications(props.actionUrl, queryRoute, props.available) + if (!props.available) return null + return app.default)?.id} failed={association.failed} + loading={association.loading} busy={props.pending} refresh={association.refresh} t={props.t} + execute={async (operation) => { + return props.onAction(operation.kind === 'reveal' ? 'reveal' : 'open', operation.kind === 'application' ? operation.id : undefined) + }} /> +} diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css b/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css deleted file mode 100644 index f5186cc9a6..0000000000 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.module.css +++ /dev/null @@ -1,64 +0,0 @@ -/* Compact Session-header split button beside the round icon buttons. */ - -.split { - display: inline-flex; - align-items: stretch; - box-sizing: border-box; - height: 24px; - border: 0.5px solid var(--dsw-alias-border-l4); - border-radius: 9px; - overflow: hidden; - font-family: var(--dsw-font-family); -} - -.main, -.chevron { - display: inline-flex; - align-items: center; - gap: 3px; - border: 0; - background: none; - color: var(--dsw-alias-label-primary); - font-size: 11px; - font-weight: 400; - line-height: 16px; - cursor: pointer; - white-space: nowrap; -} - -.main { - padding: 3px 5px; -} - -.main:hover:not(:disabled), -.main:focus-visible, -.chevron:hover, -.chevron:focus-visible { - background: var(--dsw-alias-interactive-bg-hover); -} - -.main:disabled { - color: var(--dsw-alias-label-dimmed); - cursor: wait; -} - -.main[data-state='error'] { - color: var(--dsw-alias-state-error-primary); - box-shadow: inset 0 0 0 1px var(--dsw-alias-state-error-primary); -} - -.chevron { - padding: 3px 4px 3px 3px; - border-left: 0.5px solid var(--dsw-alias-border-l4); - color: var(--dsw-alias-label-secondary); -} - -.icon { - flex: none; -} - -img.icon { - display: block; - object-fit: contain; - user-select: none; -} diff --git a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx index 78f547a802..a58ed63933 100644 --- a/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenInAppAction.tsx @@ -1,10 +1,8 @@ -import { useEffect, useRef, useState } from 'react' -import { IconChevronDownOutlineRegular, Menu, Tooltip, type MenuItem } from '@deepseek-ai/dsh-client-ui-primitives' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { NS, type OpenInAppKey } from './locales.ts' -import css from './OpenInAppAction.module.css' +import { OpenTargetButton } from './OpenTargetButton.tsx' /** Browser operations and state injected into the Session Header contribution. */ export interface OpenInAppActionInjected { @@ -65,162 +63,36 @@ const APP_LABEL_KEY: Record = { konsole: 'app.konsole', } -/** App ids whose icon image already failed this page; a 404 icon is fetched once, not per menu open. */ -const failedIcons = new Set() - /** - * One application's real bundle icon (host-served PNG) with an inline generic - * app-square fallback while the host has none. - * @param props - catalog id, host icon URL, and rendered size. - * @returns the icon image or its fallback glyph. - */ -function AppIcon({ id, url, size }: { id: string; url: string; size: number }): React.JSX.Element { - const [failed, setFailed] = useState(failedIcons.has(id)) - if (failed) { - return ( - - - - ) - } - return ( - { - failedIcons.add(id) - setFailed(true) - }} - /> - ) -} - -/** - * Quick launches settle well under this delay, so their busy dress never - * paints — the visible dim-and-wait treatment is reserved for launches that - * are actually taking a while, instead of flashing on every click. - */ -const BUSY_DRESS_DELAY_MS = 250 - -/** - * Session-header split button: the main button opens the session's workspace - * directory in the remembered application, the chevron opens the menu of - * every application the host probed as installed. It renders nothing until - * the host reported at least one nameable application and the session has a - * known workspace directory, so a host without the capability never grows - * the control. - * @param props - session runtime, injected controller face, and localized copy. - * @returns the split button and its menu, or null when there is nothing to offer. + * Adapt the installed directory catalog to the shared opening control. + * @param props - workspace state, installed catalog, and launch operations. + * @returns the shared control, or null without an eligible application and directory. */ export function OpenInAppAction(props: OpenInAppActionProps): React.JSX.Element | null { const { sessionId, useSessions, useOpenInAppApps, useOpenInAppChoice, t } = props const cwd = useSessions(state => state.byId[sessionId]?.cwd) const available = useOpenInAppApps(apps => apps) const choice = useOpenInAppChoice(id => id) - const [open, setOpen] = useState(false) - const [phase, setPhase] = useState<'idle' | 'busy' | 'error'>('idle') - const inFlight = useRef(false) - const busyTimer = useRef | undefined>(undefined) - const errorTimer = useRef | undefined>(undefined) - - useEffect(() => () => { - clearTimeout(busyTimer.current) - clearTimeout(errorTimer.current) - }, []) - - const apps = (available ?? []) - .map(id => ({ id, labelKey: APP_LABEL_KEY[id] })) - .filter((entry): entry is { id: string; labelKey: OpenInAppKey } => entry.labelKey !== undefined) - const currentEntry = apps.find(entry => entry.id === choice) ?? apps[0] - if (currentEntry === undefined || cwd === undefined || cwd === '') return null - - const current = currentEntry.id - const currentLabel = t(currentEntry.labelKey) - const title = phase === 'error' ? t('open.error') : t('open.title', { app: currentLabel }) - - const launch = (appId: string): void => { - if (inFlight.current) return - inFlight.current = true - // A pending error decay must not flip the button back to idle mid-launch. - clearTimeout(errorTimer.current) - clearTimeout(busyTimer.current) - busyTimer.current = setTimeout(() => { setPhase('busy') }, BUSY_DRESS_DELAY_MS) - props.launch(appId, cwd).then(() => { - inFlight.current = false - clearTimeout(busyTimer.current) - setPhase('idle') - }, () => { - inFlight.current = false - clearTimeout(busyTimer.current) - setPhase('error') - clearTimeout(errorTimer.current) - errorTimer.current = setTimeout(() => { setPhase('idle') }, 2_000) - }) - } - - const items: MenuItem[] = apps.map(entry => ({ - id: entry.id, - label: t(entry.labelKey), - icon: , - })) - + const apps = (available ?? []).flatMap((id) => { + const key = APP_LABEL_KEY[id] + return key === undefined ? [] : [{ id, name: t(key), icon: props.iconUrl(id) }] + }) + const preferred = apps.find(app => app.id === choice) ?? apps[0] + if (preferred === undefined || cwd === undefined || cwd === '') return null return ( - { setOpen(false) }} - items={items} - selectedId={current} - onSelect={(id) => { - setOpen(false) - // A pick while a launch is in flight is ignored whole: persisting the - // choice without launching would leave the button naming an app the - // gesture never opened. - if (inFlight.current) return - props.choose(id) - launch(id) + { + const id = operation.kind === 'application' ? operation.id : preferred.id + try { + await props.launch(id, cwd) + } catch (_error) { + // Native launch failure is announced by the shared control. + return 'openError' + } + if (operation.kind === 'application') props.choose(id) + return null }} - anchor={( -
      - - - - -
      - )} /> ) } diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx index bc7f842386..2b6008d9b1 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathAction.tsx @@ -1,20 +1,14 @@ -/** - * Document-header split button: the main button opens the previewed file in - * its default application, the chevron's menu adds the file-manager reveal. - * Both path controls share the gesture hook declared here. - */ -import { useEffect, useState } from 'react' +/** File association adapter for the shared file/directory opening control. */ +import { useEffect } from 'react' import type { ReactNode } from 'react' -import { - IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, -} from '@deepseek-ai/dsh-client-ui-primitives' +import type { SessionWorkspacePathApplication } from '@deepseek-ai/dsh-api-session-controller/types' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' -import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' import type { OpenInAppPathAction, OpenInAppPathFailure } from './open-path.ts' -import { useOpenFailureToast } from './open-failure-toast.tsx' +import { useFileApplications } from './file-applications.ts' +import { OpenTargetButton } from './OpenTargetButton.tsx' import type { NS } from './locales.ts' -import css from './OpenPathAction.module.css' /** Desktop availability and the gesture carrier injected into both path controls. */ export interface OpenPathInjected { @@ -22,7 +16,8 @@ export interface OpenPathInjected { openInAppDesktop: ObservableSnapshot } loadDesktop: () => Promise - openPath: (path: string, action: OpenInAppPathAction) => Promise + openPath: (path: string, action: OpenInAppPathAction, application?: string) => Promise + applications: (path: string, signal: AbortSignal) => Promise } /** Full props of the document-header contribution. */ @@ -31,102 +26,41 @@ export type OpenPathActionProps = & PropsLocale & InjectFace -/** What one path control needs from its props: the file, the injected face, and copy. */ -type PathGestureProps = Pick & { - t: TranslateNS +/** File inputs shared by the document header and its unpreviewable state. */ +type FileOpenTargetProps = Pick & { + empty?: boolean } /** - * Desktop availability, this control's own pending state, and a gesture - * runner announcing failures through this control's toast. Only the control - * that ran the gesture disables while it settles; failure never persists on - * the control. - * @param props - the file's Host path, the injected face, and copy. - * @returns availability, pending, the toast to render, and the runner. + * Resolve file associations and adapt operations without embedding platform behavior in the control. + * @param props - verified file path, desktop query, native operations, and display variant. + * @returns the shared opening control, or null without a desktop. */ -export function usePathGesture({ absolutePath, useOpenInAppDesktop, loadDesktop, openPath, t }: PathGestureProps): { - available: boolean - pending: boolean - toast: ReactNode - act: (action: OpenInAppPathAction) => void -} { - const desktop = useOpenInAppDesktop(value => value) - const [pending, setPending] = useState(false) - const { toast, show } = useOpenFailureToast() +export function FileOpenTarget(props: FileOpenTargetProps): ReactNode { + const desktop = props.useOpenInAppDesktop(value => value) + const association = useFileApplications(props.absolutePath, props.applications, desktop === true) useEffect(() => { - if (desktop === null) void loadDesktop() - }, [desktop, loadDesktop]) - return { - available: desktop === true, - pending, - toast, - act: (action) => { - setPending(true) - void openPath(absolutePath, action).then((failure) => { - if (failure !== null) show(t(`path.${failure}`)) - }).finally(() => { setPending(false) }) - }, - } -} - -/** - * Render the split button, or nothing until the Host reports a desktop. - * @param props - the previewed file, the injected face, and copy. - * @returns the split button with its menu and toast, or null. - */ -export function OpenPathAction(props: OpenPathActionProps): ReactNode { - const { t } = props - const [menuOpen, setMenuOpen] = useState(false) - const { available, pending, toast, act } = usePathGesture(props) - if (!available) return null - const run = (action: OpenInAppPathAction): void => { - setMenuOpen(false) - act(action) - } + if (desktop === null) void props.loadDesktop() + }, [desktop, props.loadDesktop]) + if (desktop !== true) return null + const { apps } = association return ( - <> - { setMenuOpen(false) }} - items={[ - { id: 'open', icon: , label: t('path.defaultApp') }, - { id: 'reveal', icon: , label: t('path.reveal') }, - ]} - onSelect={(id) => { run(id === 'reveal' ? 'reveal' : 'open') }} - anchor={( -
      - - - - -
      - )} - /> - {toast} - + app.default)?.id} + loading={association.loading} failed={association.failed} + prominent={props.empty === true} t={props.t} + refresh={association.refresh} + execute={operation => props.openPath(props.absolutePath, operation.kind === 'reveal' ? 'reveal' : 'open', + operation.kind === 'application' ? operation.id : undefined)} + /> ) } + +/** + * Render the file adapter in the document header. + * @param props - document owner inputs and injected opening capabilities. + * @returns the shared split button. + */ +export function OpenPathAction(props: OpenPathActionProps): ReactNode { + return +} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css deleted file mode 100644 index cf9036e0d6..0000000000 --- a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.module.css +++ /dev/null @@ -1,26 +0,0 @@ -/* The empty state's capsule, sized like the preview's own Retry - (ui-sidebar-documentpreview TextPreview `.retry`) so the two recourses read - as one family. */ -.open { - display: inline-flex; - flex: none; - gap: 6px; - align-items: center; - height: 32px; - padding: 0 12px 0 14px; - color: var(--dsw-alias-label-primary); - font-size: var(--dsh-content-font-size-secondary, 13px); - font-family: inherit; - background: transparent; - border: 0.5px solid var(--dsw-alias-border-l2); - border-radius: 16px; - cursor: pointer; -} - -.open:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} - -.open:disabled { - cursor: default; -} diff --git a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx index bf86006ed5..05a2b66800 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx +++ b/packages/client/ui-open-in-app/src/client/OpenPathEmptyAction.tsx @@ -1,11 +1,9 @@ -/** Empty-state contribution: open a file the document preview cannot render in its default application. */ +/** File opening action in an unpreviewable document's empty state. */ import type { ReactNode } from 'react' -import { IconRightUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' import type { NS } from './locales.ts' -import { usePathGesture, type OpenPathInjected } from './OpenPathAction.tsx' -import css from './OpenPathEmptyAction.module.css' +import { FileOpenTarget, type OpenPathInjected } from './OpenPathAction.tsx' /** Full props of the unpreviewable empty-state contribution. */ export type OpenPathEmptyActionProps = @@ -14,27 +12,10 @@ export type OpenPathEmptyActionProps = & InjectFace /** - * Render the default-application open button, or nothing until the Host reports a desktop. - * @param props - the unpreviewable file, the injected face, and copy. - * @returns the open button and its toast, or null. + * Render the shared file opening menu with a larger labeled main button. + * @param props - unpreviewable file and injected opening capabilities. + * @returns the shared file opening action. */ export function OpenPathEmptyAction(props: OpenPathEmptyActionProps): ReactNode { - const { t } = props - const { available, pending, toast, act } = usePathGesture(props) - if (!available) return null - return ( - <> - - {toast} - - ) + return } diff --git a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css similarity index 54% rename from packages/client/ui-open-in-app/src/client/OpenPathAction.module.css rename to packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css index f23f590613..44a66f59f2 100644 --- a/packages/client/ui-open-in-app/src/client/OpenPathAction.module.css +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.module.css @@ -1,4 +1,4 @@ -/* Sized to sit in the document header's 28px control row beside its round +/* Sized to sit in the document header's 24px control row beside its round icon buttons: a hairline pill with a 12px label and a narrow chevron half. */ .menuAnchor { @@ -11,10 +11,10 @@ display: inline-flex; align-items: stretch; box-sizing: border-box; - height: 28px; + height: 24px; overflow: hidden; - border: 0.5px solid var(--dsw-alias-border-l3); - border-radius: 14px; + border: 0.5px solid var(--dsw-alias-border-l4); + border-radius: 9px; font-family: var(--dsw-font-family); } @@ -26,19 +26,20 @@ border: 0; background: none; color: var(--dsw-alias-label-primary); - font-size: 12px; + font-size: 11px; line-height: 16px; white-space: nowrap; cursor: pointer; } .main { - padding: 5px 8px 5px 10px; + gap: 4px; + padding: 3px 5px; } .chevron { - padding: 5px 6px 5px 4px; - border-left: 0.5px solid var(--dsw-alias-border-l3); + padding: 3px 4px 3px 3px; + border-left: 0.5px solid var(--dsw-alias-border-l4); color: var(--dsw-alias-label-secondary); } @@ -54,3 +55,26 @@ .chevron:disabled { cursor: default; } + +.appIcon { + object-fit: contain; +} + +/* The unavailable preview emphasizes the same actions with a labeled button. */ +.split[data-size='large'] { + height: 40px; + border-radius: 14px; + border-color: var(--dsw-alias-border-l2); +} + +.split[data-size='large'] .main { + gap: 6px; + padding: 8px 14px; + font-size: 14px; +} + +.split[data-size='large'] .chevron { + padding: 8px 10px; +} + +.skeleton { display: inline-block; flex: none; border-radius: 3px; background: var(--dsw-alias-interactive-bg-hover); } diff --git a/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx new file mode 100644 index 0000000000..93a3a34657 --- /dev/null +++ b/packages/client/ui-open-in-app/src/client/OpenTargetButton.tsx @@ -0,0 +1,144 @@ +/** Shared file and directory opener: one default action, application menu, and per-gesture feedback. */ +import { useRef, useState } from 'react' +import type { ReactNode } from 'react' +import { + IconChevronDownOutlineRegular, IconFolderOpenOutlineRegular, IconRightUpOutlineRegular, Menu, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import type { OpenInAppPathFailure } from './open-path.ts' +import { useOpenFailureToast } from './open-failure-toast.tsx' +import type { NS } from './locales.ts' +import css from './OpenTargetButton.module.css' + +/** Application metadata supplied by either the directory catalog or a file association query. */ +export interface OpenTargetApplication { + readonly id: string + readonly name: string + readonly icon: string | null +} + +/** Opening intent; default selection remains owned by the target's adapter. */ +export type OpenTargetOperation = { readonly kind: 'default' } | { readonly kind: 'application'; readonly id: string } | { readonly kind: 'reveal' } + +/** Inputs shared by both target adapters and the file empty-state action. */ +export interface OpenTargetButtonProps { + readonly kind: 'file' | 'directory' + readonly applications: readonly OpenTargetApplication[] + readonly defaultId: string | undefined + readonly failed: boolean + readonly busy?: boolean + readonly loading?: boolean + readonly prominent?: boolean + readonly t: TranslateNS + readonly execute: (operation: OpenTargetOperation) => Promise + readonly refresh?: () => void +} + +/** + * Serialize gestures and announce their failures through the initiating control's toast. + * @param execute - target adapter that returns the failure to announce, or null. + * @param t - localized control copy. + * @returns the pending state, feedback, and guarded action callback. + */ +export function useOpenTargetGesture(execute: OpenTargetButtonProps['execute'], t: TranslateNS): { + pending: boolean + toast: ReactNode + act: (operation: OpenTargetOperation) => void +} { + const [pending, setPending] = useState(false) + const inFlight = useRef(false) + const { toast, show } = useOpenFailureToast() + return { + pending, + toast, + act: (operation) => { + if (inFlight.current) return + inFlight.current = true + setPending(true) + void execute(operation).then((failure) => { + if (failure !== null) show(t(`path.${failure}`)) + }).finally(() => { inFlight.current = false; setPending(false) }) + }, + } +} + +/** One application image with a per-image fallback, shared by main and menu buttons. */ +function ApplicationIcon({ source, size = 14 }: { source: string | null; size?: number }): ReactNode { + const [failed, setFailed] = useState(false) + return source === null || failed + ? + : { setFailed(true) }} /> +} + +/** + * Render identical split buttons for files and directories. File reveal always stays last and becomes the default when no app is selected. + * @param props - target applications, default selection, and operations. + * @returns the control and its transient failure feedback. + */ +export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode { + const { applications, defaultId, kind, t } = props + const [menuOpen, setMenuOpen] = useState(false) + const { pending, toast, act } = useOpenTargetGesture(props.execute, t) + const preferred = applications.find(app => app.id === defaultId) + const disabled = pending || props.busy === true || props.loading === true + const hasMenu = props.loading === true || props.failed || applications.length + (kind === 'file' ? 1 : 0) > 1 + const revealDefault = kind === 'file' && preferred === undefined && props.loading !== true + const primaryLabel = preferred === undefined ? t('path.reveal') : t('open.title', { app: preferred.name }) + const run = (operation: OpenTargetOperation): void => { setMenuOpen(false); act(operation) } + const primary = (): void => { run({ kind: revealDefault ? 'reveal' : 'default' }) } + const icon = props.loading === true && preferred === undefined + ?