From fc43a2d507ec4ff079a02efde824cb07aa3f17a8 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Tue, 20 Oct 2026 11:13:38 +0800 Subject: [PATCH 01/32] feat(forge): add the pull request panel and worktree Sessions The forge panel shows the pull requests a Session created or pushed in a right-sidebar tab, with a Session header button and branch label, and lets a new Session start in its own Git worktree from the New Session options row. The forge bundle ships the panel with the forge registry, the GitHub provider, the Session bindings, and an isolated worktree service. The panel listens to the agent-controller/admission stage. It holds the first message of a Session with a worktree request while it creates and enters the worktree as Agent maintenance, and refuses messages while the worktree the Session works in is gone. A failed or stopped creation returns the message to the composer: the card above the composer shows the failure, sending again retries with a new branch name, and Continue without a worktree withdraws the request. A removed worktree can be checked out again or left for the source checkout. A message that enters without admission still gets its worktree at the first step proposal. --- .../feature/2026-10-09-forge-panel.i18n.yaml | 22 + .../feature/2026-10-09-forge-panel.md | 60 + .../feature/2026-10-09-forge-panel.zh.md | 60 + ...-session-change-request-bindings.i18n.yaml | 4 +- ...6-10-09-session-change-request-bindings.md | 4 +- ...0-09-session-change-request-bindings.zh.md | 4 +- apps/cli/package.json | 3 +- apps/web/package.json | 3 +- .../expected/forge-panel/panel.expected.md | 19 + .../plugin-config/official.expected.md | 4 + .../plugin-manager/live-enabled.expected.md | 4 + .../plugin-manager/manager.expected.md | 4 + .../plugin-manager/refresh.expected.md | 4 +- apps/web/tests/forge-panel.e2e.ts | 324 +++++ apps/web/tsconfig.json | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 10 +- docs/capability-seams.zh.md | 10 +- docs/config-catalog.md | 34 +- docs/config-catalog.zh.md | 34 +- docs/event-producer-consumer.md | 8 +- docs/event-producer-consumer.zh.md | 8 +- ...experimental-persistence-catalog.i18n.yaml | 4 +- docs/experimental-persistence-catalog.md | 1 + docs/experimental-persistence-catalog.zh.md | 1 + docs/module-graph.md | 17 + docs/module-graph.zh.md | 17 + docs/subsystems/forge.i18n.yaml | 11 +- docs/subsystems/forge.md | 336 ++++- docs/subsystems/forge.zh.md | 336 ++++- docs/subsystems/worktrees.i18n.yaml | 4 +- docs/subsystems/worktrees.md | 53 +- docs/subsystems/worktrees.zh.md | 53 +- packages/boot/app-boot/src/profile.ts | 1 + packages/experimental/README.i18n.yaml | 8 +- packages/experimental/README.md | 4 +- packages/experimental/README.zh.md | 4 +- .../client-ui-forge/README.i18n.yaml | 37 + .../experimental/client-ui-forge/README.md | 112 ++ .../experimental/client-ui-forge/README.zh.md | 112 ++ .../client-ui-forge/locale/en.json | 6 + .../client-ui-forge/locale/zh.json | 6 + .../experimental/client-ui-forge/package.json | 129 ++ .../src/client/ActionToast.tsx | 38 + .../src/client/BranchChip.module.css | 39 + .../client-ui-forge/src/client/BranchChip.tsx | 62 + .../src/client/ChangeRequestCard.tsx | 328 +++++ .../src/client/ForgeTab.module.css | 490 +++++++ .../client-ui-forge/src/client/ForgeTab.tsx | 176 +++ .../src/client/ForgeTabTitle.tsx | 21 + .../src/client/HeaderButton.module.css | 35 + .../src/client/HeaderButton.tsx | 67 + .../src/client/WorktreeProgress.module.css | 220 +++ .../src/client/WorktreeProgress.tsx | 210 +++ .../src/client/WorktreeToggle.module.css | 105 ++ .../src/client/WorktreeToggle.tsx | 147 ++ .../client-ui-forge/src/client/format.ts | 37 + .../client-ui-forge/src/client/index.ts | 24 + .../client-ui-forge/src/client/locales.ts | 252 ++++ .../client-ui-forge/src/client/mount.ts | 214 +++ .../client-ui-forge/src/client/source.ts | 227 +++ .../client-ui-forge/src/client/status.ts | 117 ++ .../client-ui-forge/src/css-modules.d.ts | 5 + .../experimental/client-ui-forge/src/git.ts | 228 +++ .../experimental/client-ui-forge/src/index.ts | 377 +++++ .../experimental/client-ui-forge/src/panel.ts | 336 +++++ .../experimental/client-ui-forge/src/parse.ts | 87 ++ .../experimental/client-ui-forge/src/types.ts | 219 +++ .../client-ui-forge/src/worktree.ts | 274 ++++ .../tests/branch-chip.client.spec.tsx | 112 ++ .../tests/change-request-card.client.spec.tsx | 320 +++++ .../tests/client-fixtures.client.ts | 132 ++ .../tests/components.client.spec.tsx | 171 +++ .../tests/controller.host.spec.ts | 1226 +++++++++++++++++ .../tests/forge-tab.client.spec.tsx | 295 ++++ .../tests/format.client.spec.ts | 48 + .../client-ui-forge/tests/git.host.spec.ts | 428 ++++++ .../client-ui-forge/tests/harness.ts | 541 ++++++++ .../tests/mount.client.spec.ts | 492 +++++++ .../client-ui-forge/tests/parse.host.spec.ts | 88 ++ .../tests/source.client.spec.ts | 258 ++++ .../tests/status.client.spec.ts | 131 ++ .../tests/worktree-progress.client.spec.tsx | 280 ++++ .../tests/worktree-toggle.client.spec.tsx | 298 ++++ .../tests/worktree.host.spec.ts | 501 +++++++ .../client-ui-forge/tsconfig.client.json | 72 + .../client-ui-forge/tsconfig.host.json | 69 + .../client-ui-forge/tsconfig.json | 11 + .../client-ui-forge/tsdown.config.ts | 3 + .../forge-bundle/README.i18n.yaml | 37 + packages/experimental/forge-bundle/README.md | 77 ++ .../experimental/forge-bundle/README.zh.md | 77 ++ .../forge-bundle/cordis.patch.yml | 17 + packages/experimental/forge-bundle/icon.svg | 11 + .../experimental/forge-bundle/locale/en.json | 6 + .../experimental/forge-bundle/locale/zh.json | 6 + .../experimental/forge-bundle/package.json | 53 + .../experimental/forge-bundle/src/index.ts | 2 + .../experimental/forge-bundle/tsconfig.json | 15 + .../forge-bundle/tsdown.config.ts | 3 + .../forge-github/README.i18n.yaml | 8 +- packages/experimental/forge-github/README.md | 6 +- .../experimental/forge-github/README.zh.md | 6 +- packages/experimental/forge-github/src/gh.ts | 7 +- .../experimental/forge-github/src/graphql.ts | 25 +- .../experimental/forge-github/src/provider.ts | 14 +- .../forge-github/tests/fixtures.ts | 5 + .../forge-github/tests/gh.spec.ts | 6 +- .../forge-github/tests/graphql.spec.ts | 25 +- .../forge-github/tests/provider.spec.ts | 25 + .../forge-session/README.i18n.yaml | 4 +- packages/experimental/forge-session/README.md | 2 +- .../experimental/forge-session/README.zh.md | 2 +- .../experimental/forge-session/package.json | 3 + .../experimental/forge-session/src/types.ts | 4 +- .../forge-session/tests/fixtures.ts | 9 +- .../tests/loader-composition.spec.ts | 5 +- packages/experimental/forge/README.i18n.yaml | 8 +- packages/experimental/forge/README.md | 6 +- packages/experimental/forge/README.zh.md | 6 +- packages/experimental/forge/package.json | 3 + packages/experimental/forge/src/index.ts | 26 +- packages/experimental/forge/src/types.ts | 20 + .../experimental/forge/tests/fake-provider.ts | 9 +- .../experimental/forge/tests/registry.spec.ts | 3 + .../experimental/forge/tests/watch.spec.ts | 34 + .../experimental/worktree/README.i18n.yaml | 4 +- packages/experimental/worktree/README.md | 4 +- packages/experimental/worktree/README.zh.md | 4 +- packages/experimental/worktree/src/index.ts | 158 ++- packages/experimental/worktree/src/types.ts | 29 +- .../worktree/tests/checkout-safety.spec.ts | 23 + .../worktree/tests/worktree.spec.ts | 153 +- .../src/client/slot-catalog.ts | 10 +- .../extensions/tool-cordis/src/api-catalog.ts | 157 ++- .../references/packages.md | 1 + pnpm-lock.yaml | 155 +++ scripts/gen-cordis-catalog.ts | 7 + scripts/gen-doc-graphs.ts | 12 +- scripts/optional-bundles.spec.ts | 30 + scripts/release/families.spec.ts | 2 + scripts/type-equiv.manifest.json | 85 ++ .../verify-package-readme-model-experience.ts | 2 + tsconfig.base.json | 6 + tsconfig.client.json | 1 + tsconfig.host.json | 3 + vitest.config.ts | 7 + 147 files changed, 12984 insertions(+), 135 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-10-09-forge-panel.md create mode 100644 .agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md create mode 100644 apps/web/tests/expected/forge-panel/panel.expected.md create mode 100644 apps/web/tests/forge-panel.e2e.ts create mode 100644 packages/experimental/client-ui-forge/README.i18n.yaml create mode 100644 packages/experimental/client-ui-forge/README.md create mode 100644 packages/experimental/client-ui-forge/README.zh.md create mode 100644 packages/experimental/client-ui-forge/locale/en.json create mode 100644 packages/experimental/client-ui-forge/locale/zh.json create mode 100644 packages/experimental/client-ui-forge/package.json create mode 100644 packages/experimental/client-ui-forge/src/client/ActionToast.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/BranchChip.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/BranchChip.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ForgeTab.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/ForgeTab.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/HeaderButton.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/HeaderButton.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeToggle.module.css create mode 100644 packages/experimental/client-ui-forge/src/client/WorktreeToggle.tsx create mode 100644 packages/experimental/client-ui-forge/src/client/format.ts create mode 100644 packages/experimental/client-ui-forge/src/client/index.ts create mode 100644 packages/experimental/client-ui-forge/src/client/locales.ts create mode 100644 packages/experimental/client-ui-forge/src/client/mount.ts create mode 100644 packages/experimental/client-ui-forge/src/client/source.ts create mode 100644 packages/experimental/client-ui-forge/src/client/status.ts create mode 100644 packages/experimental/client-ui-forge/src/css-modules.d.ts create mode 100644 packages/experimental/client-ui-forge/src/git.ts create mode 100644 packages/experimental/client-ui-forge/src/index.ts create mode 100644 packages/experimental/client-ui-forge/src/panel.ts create mode 100644 packages/experimental/client-ui-forge/src/parse.ts create mode 100644 packages/experimental/client-ui-forge/src/types.ts create mode 100644 packages/experimental/client-ui-forge/src/worktree.ts create mode 100644 packages/experimental/client-ui-forge/tests/branch-chip.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/client-fixtures.client.ts create mode 100644 packages/experimental/client-ui-forge/tests/components.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/controller.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/format.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/git.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/harness.ts create mode 100644 packages/experimental/client-ui-forge/tests/mount.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/parse.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/source.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/status.client.spec.ts create mode 100644 packages/experimental/client-ui-forge/tests/worktree-progress.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/worktree-toggle.client.spec.tsx create mode 100644 packages/experimental/client-ui-forge/tests/worktree.host.spec.ts create mode 100644 packages/experimental/client-ui-forge/tsconfig.client.json create mode 100644 packages/experimental/client-ui-forge/tsconfig.host.json create mode 100644 packages/experimental/client-ui-forge/tsconfig.json create mode 100644 packages/experimental/client-ui-forge/tsdown.config.ts create mode 100644 packages/experimental/forge-bundle/README.i18n.yaml create mode 100644 packages/experimental/forge-bundle/README.md create mode 100644 packages/experimental/forge-bundle/README.zh.md create mode 100644 packages/experimental/forge-bundle/cordis.patch.yml create mode 100644 packages/experimental/forge-bundle/icon.svg create mode 100644 packages/experimental/forge-bundle/locale/en.json create mode 100644 packages/experimental/forge-bundle/locale/zh.json create mode 100644 packages/experimental/forge-bundle/package.json create mode 100644 packages/experimental/forge-bundle/src/index.ts create mode 100644 packages/experimental/forge-bundle/tsconfig.json create mode 100644 packages/experimental/forge-bundle/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml new file mode 100644 index 0000000000..2053db1e3c --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-10-09-forge-panel.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-10-09-forge-panel.md +/agent-note-pull-request-panel-and-worktree-sessions: + en: 5ef5ca5d20eeff13 + zh: 7a6b13796ab8daa8 +/agent-note-pull-request-panel-and-worktree-sessions/problem: + en: afcbe41922d3476c + zh: 18291d09efbe1343 +/agent-note-pull-request-panel-and-worktree-sessions/decision: + en: d77e478797516c02 + zh: f22d12966cca6096 +/agent-note-pull-request-panel-and-worktree-sessions/deferred: + en: 715eea526e6d7905 + zh: 96151efa9cd01d0d +/agent-note-pull-request-panel-and-worktree-sessions/alternatives-considered: + en: 5d10c2928a7e76e2 + zh: 9475c60f58b1f30f +/agent-note-pull-request-panel-and-worktree-sessions/consequences: + en: 13997aee8d8bdf53 + zh: cf23447393c56555 diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md new file mode 100644 index 0000000000..90bab12c9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.md @@ -0,0 +1,60 @@ +# Agent Note: Pull request panel and worktree Sessions + +Status: implemented + +English | [中文](2026-10-09-forge-panel.zh.md) + +## Problem + +[Session change request bindings](2026-10-09-session-change-request-bindings.md) give each Session a list of pull requests, but nothing shows it. A user who asks the agent to open pull requests needs their checks, reviews, and mergeability without leaving the conversation, and wants a new Session's edits and commits kept out of the current checkout. Git must run where the Session's commands run, which can be a remote or confined execution world; inspection must not change the repository, and a worktree must exist before the model's first action. + +## Decision + +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a header branch label, a New Session option, a creation card above the composer, and a toast. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry. + +The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads refs and configuration only: the top-level and common Git directories, the branch, the upstream, and the remotes. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun. + +Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. + +A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message enters the Session, so the message runs there and a stopped creation leaves it unsent: the panel's listener on the [`agent-controller/admission` stage](../architecture/2026-10-17-agent-controller-message-delivery.md) holds the first message while it creates and enters the worktree as Agent maintenance, then lets the message enter, and the New Session composer shows the message as sending meanwhile. The creation's stage streams to a card above the composer, where Cancel stops it and returns the message to editing. A failure also returns the message to editing, and the card keeps the failure: sending again starts a new attempt with a new branch name, and Continue without a worktree withdraws the request. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because other senders reach the Agent without admission; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The Session header names the branch the Session works on. + +The worktree a Session works in can be removed outside it. Each inspection checks whether the created worktree's directory still exists, and admission checks again for every message of a worktree Session, refusing it while the directory is missing so the message stays in the composer. Restore checks the branch out again at the recorded directory, or creates the branch again at its base commit when it is gone; Continue in the project moves the Session to the source checkout. + +The panel no longer moves a Session between existing worktrees. Removing a pull request appends a `dismiss` record, and its toast offers Undo through `track`. + +## Deferred + +A left-navigation review page next to Plugins and Scheduled tasks, listing the user's pull requests or merge requests on the selected provider, is deferred. It attaches without changing this package's Session surfaces: + +- **Provider capability** — an optional `ForgeProvider` method such as `listForViewer(host, filter, signal)` returns change request references; GitHub implements it with `gh search prs --author @me` or the GraphQL `viewer`, and the registry aggregates enabled providers. +- **Remote** — a stream like `follow` that is keyed by provider and filter instead of a Session and polls through the same `ctx.forge.watch`. +- **Client** — a `sidebar.panellist` entry with a `main` page in this package, as `ui-schedule` does, rendering `ChangeRequestCard`, which takes a change request, its snapshot, and optional actions. +- **Settings** — provider choice and hosts on the bundle's configuration page. + +## Alternatives considered + +**A Host service package for Git inspection.** No second consumer needs checkout facts yet; a model tool can move them into a shared service when it exists. + +**Running Git as a Host process.** Remote and confined execution worlds would show the Host's files instead of the Session's, and the Host would read directories the Session cannot. + +**Creating the worktree when the option is checked.** Every toggle or base change would create and abandon a checkout, and the Session would move before the user decided to send. + +**Carrying the option in the Session creation request.** The Session controller owns a closed request; a plugin record keeps the option inside this package and survives a reload of the blank Session. + +**Switching between existing worktrees from the panel.** A switched Session keeps the sandbox root of its project, so a worktree outside the project stays read-only, and moving mid-conversation splits the model's view of the files. Starting a Session in its worktree avoids both. + +**The option in the composer tool row.** The tool row stays after the first message, when the option no longer applies; the New Session row disappears once the Session starts. The row offered only single slots, so `ui-conversation` gained the list slot `conversation.hero.options`. + +**Creating the worktree within the first turn.** The message is already accepted when the first step proposal runs, so cancelling could only drop it or queue it again, and the New Session screen is already gone. A thrown pre-step error also discards the claimed message. + +**Creating the worktree in a maintenance job when a message arrives.** The message would sit in the queue and the composer would already be cleared; holding the message at admission keeps it in the composer until the worktree exists. + +**A submit gate in the Web composer.** The browser held the message before the Host saw it, so every check needed a Remote call, the stage of a refused message lived only in the browser, and other senders bypassed it. Admission holds the message where it enters the Agent and keeps the failure in the panel state. + +**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen. + +**A Session-row hover section and manual URL tracking.** Both duplicated the tab. Bindings come from `gh pr create` and pushes, and `track` remains for Undo. + +## Consequences + +The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to five short Git processes in the Session's execution world. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. Only the Web Session Remote admits messages so far; other senders create the worktree within the first turn and send into a removed worktree. Restoring loses changes that were never committed. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. diff --git a/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md new file mode 100644 index 0000000000..e6cf1f0a4e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md @@ -0,0 +1,60 @@ +# Agent Note: Pull request 面板与 worktree Session + +Status: implemented + +[English](2026-10-09-forge-panel.md) | 中文 + +## Problem + +[Session 变更请求绑定](2026-10-09-session-change-request-bindings.zh.md)为每个 Session 提供了 pull request 列表,但没有地方展示。让 agent 开 pull request 的用户需要在不离开对话的情况下看到它们的检查、评审与能否合并,也希望新 Session 的修改和提交不影响当前检出。Git 必须在 Session 命令运行的地方执行,这可能是远程或受限的执行环境;检查不能改动仓库,而且 worktree 必须在模型第一次动作之前就存在。 + +## Decision + +[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个标题栏分支标签、一个新会话选项、一张位于输入框上方的创建卡片,以及一个 toast。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。 + +Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查只读取 ref 与配置:顶层目录与公共 Git 目录、分支、上游与远端。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。 + +状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。 + +新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息进入 Session 之前就存在,这样消息才会在其中运行,停止创建时消息也不会进入:面板在 [`agent-controller/admission` 阶段](../architecture/2026-10-17-agent-controller-message-delivery.zh.md)上的监听器暂留第一条消息,同时以 Agent 维护任务创建并进入 worktree,之后再放行消息;此期间新会话页的输入框把消息显示为正在发送。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止创建并让消息回到可编辑状态。失败时消息同样回到可编辑状态,卡片保留失败信息:再次发送会用新分支名开始新的尝试,“不使用工作树”撤回请求。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为其他发送方不经过接纳就能把消息交给 Agent;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。Session 标题栏显示 Session 所在的分支。 + +Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;在 worktree 中工作的 Session 的每条消息在接纳时也会再确认一次,目录缺失时拒绝消息,让它留在输入框里。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。 + +面板不再在现有 worktree 之间移动 Session。移除 pull request 会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。 + +## Deferred + +在左侧导航“插件”“自动化任务”旁边新增代码审查页、列出用户在所选提供方上的 pull request 或 merge request,这项工作推迟。它的接入不需要改动本包面向 Session 的界面: + +- **提供方能力**——`ForgeProvider` 增加一个可选方法,例如 `listForViewer(host, filter, signal)`,返回变更请求引用;GitHub 用 `gh search prs --author @me` 或 GraphQL `viewer` 实现,注册表汇总已启用的提供方。 +- **Remote**——一条类似 `follow` 的流,以提供方与筛选条件而不是 Session 为键,通过同一个 `ctx.forge.watch` 轮询。 +- **客户端**——在本包中以 `sidebar.panellist` 条目加一个 `main` 页面实现(与 `ui-schedule` 相同),渲染接收变更请求、其快照与可选操作的 `ChangeRequestCard`。 +- **设置**——在套件的配置页中选择提供方与主机。 + +## Alternatives considered + +**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。 + +**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。 + +**勾选时立即创建 worktree。** 每次切换勾选或更换基准都会创建并丢弃一个检出,而且用户决定发送之前 Session 就已经移动。 + +**在创建 Session 的请求中携带该选项。** 这个请求由 Session 控制器拥有,字段是封闭的;插件记录让选项留在本包之内,并且在空白 Session 重新加载后仍然保留。 + +**在面板中切换现有 worktree。** 切换后的 Session 仍保留其项目的沙箱根,因此项目之外的 worktree 依然只读;对话中途移动还会让模型对文件的认识前后割裂。让 Session 从一开始就在自己的 worktree 中运行可以同时避免这两点。 + +**把选项放在输入框工具栏。** 发出第一条消息后工具栏依然存在,而那时该选项已不再适用;新会话那一行在 Session 开始后就会消失。那一行原先只提供单值 slot,因此 `ui-conversation` 增加了列表型 slot `conversation.hero.options`。 + +**在第一轮之内创建 worktree。** 第一个步骤提议运行时,消息已经被接收,取消只能丢弃它或把它放回队列,新会话页也已经离开。在 pre-step 中抛出的错误还会丢弃已认领的消息。 + +**在消息到达时用维护任务创建 worktree。** 消息会停在队列里,输入框也已经被清空;在接纳阶段暂留消息可以让它在 worktree 建好之前留在输入框里。 + +**在 Web 输入框里设发送闸门。** 浏览器在宿主看到消息之前就拦住它,因此每次检查都要一次 Remote 调用,被拒绝消息的阶段只存在于浏览器中,其他发送方也会绕过它。接纳阶段在消息进入 Agent 的位置暂留它,并把失败保留在面板状态中。 + +**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。 + +**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create` 与推送,`track` 保留给“撤销”使用。 + +## Consequences + +面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。每次检查最多在 Session 的执行环境中启动五个短暂的 Git 进程。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。目前只有 Web Session Remote 接纳消息;其他发送方在第一轮之内创建 worktree,也会向已删除的 worktree 发送消息。恢复会丢失从未提交的修改。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。 diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml index 877a85b4ba..98bff039af 100644 --- a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.i18n.yaml @@ -9,8 +9,8 @@ en: e736740546fb8219 zh: 46a16a7a633716ec /agent-note-session-change-request-bindings/decision: - en: c91bbf1c6e224e2e - zh: 8c48d97146457381 + en: f785279cfb4e8709 + zh: 32525080dc8efe93 /agent-note-session-change-request-bindings/alternatives-considered: en: 15fdaef4e432fc72 zh: 8d36c859bd89f403 diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md index 8f466a3da1..dbedb3c3e2 100644 --- a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.md @@ -12,9 +12,9 @@ A Session that opens or updates pull requests leaves them only in its shell outp The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions. -Bindings derive from the Session log instead of a separate store. A host-only projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, and `git push` results record the branches they updated. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated. +Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, and `git push` results record the branches they updated. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated. -After each push result, the service looks up open change requests for the pushed branches through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. +After each push result, the service looks up open change requests for the pushed branches through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md index 24d9703c4d..1748ba919b 100644 --- a/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md +++ b/.agents/notes/implemented/feature/2026-10-09-session-change-request-bindings.zh.md @@ -12,9 +12,9 @@ Status: implemented 实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。 -绑定从 Session 日志派生,而不是存放在单独的存储中。一个仅宿主侧的投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果记录其更新的分支。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。 +绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果记录其更新的分支。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。 -每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。 +每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。 ## Alternatives considered diff --git a/apps/cli/package.json b/apps/cli/package.json index d7c3e78f72..b04682ffd6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -106,7 +106,8 @@ "@deepseek-ai/dsh-experimental-session-search": "workspace:*", "@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*", "@deepseek-ai/dsh-cua-bundle": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" }, "devDependencies": { "@deepseek-ai/dsh-skill-badge": "workspace:*", diff --git a/apps/web/package.json b/apps/web/package.json index 6e90bc5d2c..b81b1dba3d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-experimental-voice-input-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-cot-translation-bundle": "workspace:*", "@deepseek-ai/dsh-experimental-worktree": "workspace:*", - "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*" + "@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*" } } diff --git a/apps/web/tests/expected/forge-panel/panel.expected.md b/apps/web/tests/expected/forge-panel/panel.expected.md new file mode 100644 index 0000000000..4b340e0efd --- /dev/null +++ b/apps/web/tests/expected/forge-panel/panel.expected.md @@ -0,0 +1,19 @@ +- text: 工作树 dsh/{{worktree}} 基于 release +- heading "本会话的 Pull Request · 1" [level=3] +- button "刷新状态" +- list: + - listitem: + - 'button "Add the pull request panel #7 feat +128 −14 1 项失败" [expanded]' + - button "查看 PR" + - button "复制 PR 链接" + - text: 状态 可供审查 · 手动跟踪 + - button "检查 1 项失败 · 1 项通过" [expanded] + - list: + - listitem: + - button "test 2 分 7 秒" + - listitem: + - button "lint 42 秒" + - text: 评审 等待评审 · 1 条未解决讨论 合并 可合并 + - separator + - button "添加到聊天" + - button "从会话中移除 PR" diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 27c2e72887..b95862b22d 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -42,6 +42,10 @@ - button "查看 Computer Use": Computer Use - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - switch "启用 Computer Use" + - listitem: + - button "查看 PR 与工作树": PR 与工作树 + - text: 实验性 跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问 + - switch "启用 PR 与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index cfe553e2bb..a21ecf4382 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -42,6 +42,10 @@ - button "查看 Computer Use": Computer Use - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - switch "启用 Computer Use" + - listitem: + - button "查看 PR 与工作树": PR 与工作树 + - text: 实验性 跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问 + - switch "启用 PR 与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index d58ad62b03..56d7479ae9 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -42,6 +42,10 @@ - button "查看 Computer Use": Computer Use - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - switch "启用 Computer Use" + - listitem: + - button "查看 PR 与工作树": PR 与工作树 + - text: 实验性 跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问 + - switch "启用 PR 与工作树" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 85d666fd3e..56871c9d6b 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":13,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":13,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts new file mode 100644 index 0000000000..71ba44fa09 --- /dev/null +++ b/apps/web/tests/forge-panel.e2e.ts @@ -0,0 +1,324 @@ +/** Worktree Sessions and the pull request panel through the real optional bundle, a Git checkout, and a scripted GitHub CLI. */ +import { execFile } from 'node:child_process' +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import type {} from '@deepseek-ai/dsh-experimental-forge-session' +import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { chromium, type Browser, type Page } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { + captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, launchWebScaffold, readPersistedEvents, watchConsole, + webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspaceZh, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE } from './support.ts' + +const BUNDLE = fileURLToPath(new URL('../../../packages/experimental/forge-bundle', import.meta.url)) +const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v4.jsonl', import.meta.url)) +const EXPECTED = fileURLToPath(new URL('./expected/forge-panel', import.meta.url)) +const MODE = webSnapshotMode() +const PULL = 'https://github.com/octo/demo/pull/7' +const run = promisify(execFile) + +/** + * GitHub CLI double: signed in to github.com; GraphQL serves pull request 7 of + * octo/demo with one failed and one passed check run, one unresolved thread, + * and a required review. Every invocation is appended to `calls.jsonl`. + */ +const GH = `#!/usr/bin/env node +import { appendFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +const args = process.argv.slice(2) +appendFileSync(join(dirname(fileURLToPath(import.meta.url)), 'calls.jsonl'), JSON.stringify(args) + '\\n') +if (args[0] === 'auth' && args[1] === 'status') { + process.stdout.write('github.com\\n Logged in to github.com account octo\\n') + process.exit(0) +} +if (args[0] === 'api' && args[1] === 'graphql') { + const query = args.find(arg => arg.startsWith('query=')).slice('query='.length) + const check = (name, conclusion, completedAt) => ({ + __typename: 'CheckRun', name, status: 'COMPLETED', conclusion, + detailsUrl: 'https://github.com/octo/demo/actions/runs/1', startedAt: '2026-10-09T00:00:00Z', completedAt, + checkSuite: { workflowRun: { workflow: { name: 'CI' } } }, + }) + const pull = number => number !== 7 ? null : { + number, url: 'https://github.com/octo/demo/pull/7', title: 'Add the pull request panel', state: 'OPEN', isDraft: false, + updatedAt: '2026-10-09T00:00:00Z', closedAt: null, author: { login: 'octo' }, headRefName: 'feat', headRefOid: 'a'.repeat(40), + baseRefName: 'main', additions: 128, deletions: 14, mergeable: 'MERGEABLE', reviewDecision: 'REVIEW_REQUIRED', + reviewThreads: { nodes: [{ isResolved: false }] }, + commits: { nodes: [{ commit: { statusCheckRollup: { state: 'FAILURE', contexts: { + pageInfo: { hasNextPage: false }, + nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z'), check('test', 'FAILURE', '2026-10-09T00:02:07Z')], + } } } }] }, + } + const data = {} + let repository + for (const match of query.matchAll(/(r\\d+): repository\\(|(p\\d+): pullRequest\\(number: (\\d+)\\)/g)) { + if (match[1] !== undefined) data[repository = match[1]] = {} + else data[repository][match[2]] = pull(Number(match[3])) + } + process.stdout.write(JSON.stringify({ data })) + process.exit(0) +} +process.stderr.write('unexpected gh invocation\\n') +process.exit(1) +` + +async function git(cwd: string, ...args: string[]): Promise { + const { stdout } = await run('git', args, { cwd, env: { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_TERMINAL_PROMPT: '0' } }) + return stdout.trim() +} + +/** A Web scaffold with the bundle, a GitHub checkout of `main` and `release`, and a fresh Session whose worktree option chose `release`. */ +interface ReleaseRequest { + readonly scaffold: WebScaffold + readonly page: Page + /** Temporary directory of the GitHub CLI double and its call log. */ + readonly root: string + /** The Session's workspace, a main checkout. */ + readonly project: string + readonly tripwire: ReturnType +} + +/** + * Launch the bundle against a GitHub checkout of `main` and `release`, open a fresh Session, and choose the worktree option from `release`. + * @param failureShot - name of the screenshot saved when the test fails. + * @returns the scaffold, page, and checkout. + */ +async function requestReleaseWorktree(failureShot: string): Promise { + const resources: { scaffold?: WebScaffold; browser?: Browser } = {} + const root = await mkdtemp(join(tmpdir(), 'dsh-forge-panel-')) + onTestFinished(async () => { + const failures: unknown[] = [] + await resources.browser?.close().catch((error: unknown) => failures.push(error)) + await resources.scaffold?.close().catch((error: unknown) => failures.push(error)) + await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'Forge panel teardown failed') + }) + const gh = join(root, 'gh') + await writeFile(gh, GH) + await chmod(gh, 0o755) + const overlay = join(root, 'forge.patch.yml') + await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`) + const scaffold = await launchWebScaffold({ + profile: { packages: [{ dir: BUNDLE, enabled: true }] }, + extraOverlayPath: overlay, + replayFixture: FIXTURE, + // The worktree notice, worktree records, and bindings are asserted separately from the borrowed conversation. + compareReplaySession: false, + }) + resources.scaffold = scaffold + + const project = join(scaffold.workspaceCwd, 'workspace') + await mkdir(project, { recursive: true }) + await git(project, 'init', '--quiet', '--initial-branch=main') + await git(project, 'config', 'user.name', 'Forge Panel') + await git(project, 'config', 'user.email', 'forge-panel@example.test') + await git(project, 'config', 'commit.gpgsign', 'false') + await writeFile(join(project, 'README.md'), 'demo\n') + await git(project, 'add', 'README.md') + await git(project, 'commit', '--quiet', '-m', 'init') + await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git') + await git(project, 'branch', 'release') + await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only') + + const browser = await chromium.launch() + resources.browser = browser + const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE, timezoneId: 'Asia/Shanghai' }) + const tripwire = watchConsole(page) + onTestFailed(async () => { await saveFailureShot(page, failureShot) }) + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspaceZh(page, scaffold.workspaceCwd) + + const option = page.locator('[data-forge-worktree]') + await option.waitFor({ timeout: 20_000 }) + await option.getByRole('checkbox', { name: '工作树', exact: true }).check() + await option.getByRole('button', { name: '基于 main', exact: true }).click() + await page.getByRole('menuitem', { name: 'release', exact: true }).click() + await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() + return { scaffold, page, root, project, tripwire } +} + +/** + * Send the first prompt of the borrowed scenario. + * @returns the prompt and the turn's settlement, which resolves to the Session id. + */ +async function sendPrompt(page: Page, scaffold: WebScaffold): Promise<{ readonly prompt: string; readonly settled: Promise }> { + const [prompt] = fixtureUserPrompts(await readFile(FIXTURE, 'utf8')) + if (prompt === undefined) throw new Error('The borrowed scenario requires a user prompt') + const settled = scaffold.whenTurnSettled() + const composer = page.locator('[data-composer-input]').first() + await writeComposerDraft(page, composer, prompt) + await composer.press('Enter') + return { prompt, settled } +} + +function worktreeRecords(events: readonly SessionEvent[]): unknown[] { + return events.flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:client-ui-forge/worktree' ? [record.data] : [] + }) +} + +it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its pull request, and restores the removed worktree', async () => { + const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel') + const { settled } = await sendPrompt(page, scaffold) + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await page.locator('[data-forge-creation]').waitFor({ state: 'detached' }) + await page.locator('[data-forge-worktree]').waitFor({ state: 'detached' }) + + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The worktree Session must stay loaded') + const directory = scaffold.ctx.workingDirectory.get(session) + const branch = `dsh/${basename(directory)}` + expect(directory).toBe(join(project, '.agents', 'worktrees', branch)) + expect(branch).toMatch(/^dsh\/[0-9a-f]{8}$/) + expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch) + const baseCommit = await git(project, 'rev-parse', 'release') + expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit) + const events = await readPersistedEvents(scaffold, sessionId) + expect(worktreeRecords(events)).toEqual([ + { action: 'request', from: 'main' }, + { action: 'request', from: 'release' }, + { action: 'created', worktree: { path: directory, branch, from: 'release', baseCommit, repositoryRoot: project } }, + ]) + // The worktree existed before the turn, so the first model request carried its directory notice. + const created = events.findIndex((event) => { + const record = pluginRecordOf(event) + const data = record?.type === 'plugin:client-ui-forge/worktree' ? record.data : null + return typeof data === 'object' && data !== null && 'action' in data && data.action === 'created' + }) + const turn = events.findIndex(event => event.type === 'turn/start') + const notice = events.findIndex(event => event.type === 'user/message' && event.data.source.kind === 'working-directory') + expect(created).toBeLessThan(turn) + expect(notice).toBeGreaterThan(turn) + expect(notice).toBeLessThan(events.findIndex(event => event.type === 'assistant/message')) + + const label = page.locator('[data-forge-branch]') + await expect.poll(() => label.textContent(), { timeout: 20_000 }).toBe(branch) + await label.hover() + await page.getByRole('tooltip').filter({ hasText: `工作树:${directory},基于 release 创建` }).waitFor() + await page.mouse.move(0, 0) + + // A pull request bound the way a `gh pr create` result binds one. + scaffold.ctx.forgeSession.track(session, PULL) + const header = page.locator('[data-forge-header]') + await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的 PR:有需要处理的项') + expect(await header.getAttribute('aria-pressed')).toBe('false') + await header.click() + await expect.poll(() => header.getAttribute('aria-pressed')).toBe('true') + const panel = page.locator('[data-forge-panel]') + const checkout = panel.locator('[data-forge-checkout]') + await checkout.getByText(branch, { exact: true }).waitFor({ timeout: 20_000 }) + await checkout.getByText('基于 release', { exact: true }).waitFor() + const change = panel.locator(`[data-forge-change="${PULL}"]`) + await change.getByText('Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 }) + await change.getByText('1 项失败', { exact: true }).waitFor() + await change.getByRole('button', { name: /^Add the pull request panel/ }).click() + await change.getByRole('button', { name: /^检查/ }).click() + await change.getByText('test', { exact: true }).waitFor() + await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'), + (await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd)).replaceAll(branch, 'dsh/{{worktree}}'), MODE) + + const composer = page.locator('[data-composer-input]').first() + await change.getByRole('button', { name: '添加到聊天', exact: true }).click() + await expect.poll(() => composer.textContent()).toContain(PULL) + + const bindings = async () => (await readPersistedEvents(scaffold, sessionId)).flatMap((event) => { + const record = pluginRecordOf(event) + return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] + }) + await change.getByRole('button', { name: '从会话中移除 PR', exact: true }).click() + await change.waitFor({ state: 'detached' }) + await panel.getByText('本会话还没有 PR', { exact: true }).waitFor() + await page.getByRole('alert').filter({ hasText: '已从会话中移除 #7' }).getByRole('button', { name: '撤销', exact: true }).click() + await change.getByText('Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 }) + await expect.poll(bindings).toEqual([{ action: 'track', url: PULL }, { action: 'dismiss', url: PULL }, { action: 'track', url: PULL }]) + + const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + expect(calls).toContainEqual(['auth', 'status', '--hostname', 'github.com']) + expect(calls.some(args => args[0] === 'api' && args[1] === 'graphql' && args.includes('--hostname'))).toBe(true) + await header.click() + await expect.poll(() => header.getAttribute('aria-pressed')).toBe('false') + + // Removing the worktree outside the Session returns the next message to the composer until the worktree is restored. + await git(project, 'worktree', 'remove', '--force', directory) + const refused = page.waitForResponse('**/api/session/prompt') + await composer.press('Enter') + const envelope = await (await refused).json() as { readonly result: unknown } + expect(envelope.result).toMatchObject({ + ok: false, error: { code: 'session/prompt-rejected', details: { reason: 'forge/worktree-missing', presented: true } }, + }) + const missing = page.locator('[data-forge-missing]') + await missing.getByText(`可以在原路径重新检出分支 ${branch};删除前未提交的修改无法找回`, { exact: true }).waitFor() + await expect.poll(() => label.getAttribute('data-missing')).toBe('') + await page.locator('[data-composer-input][contenteditable="true"]').waitFor() + expect(await composer.textContent()).toContain(PULL) + // The card presents the refusal, so the composer adds no notice of its own. + expect(await page.getByRole('alert').filter({ hasText: 'was removed' }).count()).toBe(0) + expect((await readPersistedEvents(scaffold, sessionId)).filter(event => event.type === 'turn/start')).toHaveLength(1) + await missing.getByRole('button', { name: '恢复工作树', exact: true }).click() + await missing.waitFor({ state: 'detached', timeout: 20_000 }) + await page.getByRole('alert').filter({ hasText: '工作树已恢复' }).waitFor() + expect(await git(directory, 'rev-parse', '--abbrev-ref', 'HEAD')).toBe(branch) + expect(await git(directory, 'rev-parse', 'HEAD')).toBe(baseCommit) + await expect.poll(() => label.getAttribute('data-missing')).toBeNull() + expect(await composer.textContent()).toContain(PULL) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) + +it.skipIf(MODE === 'record')('returns the first message to the New Session composer when the worktree cannot be created, retries on the next send, and sends it without the worktree', async () => { + const { scaffold, page, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel-failure') + await git(project, 'branch', '--quiet', '-D', 'release') + const { prompt, settled } = await sendPrompt(page, scaffold) + const card = page.locator('[data-forge-creation="failed"]') + const failure = card.getByRole('region', { name: '创建工作树失败', exact: true }) + await failure.getByText('原因', { exact: true }).waitFor({ timeout: 20_000 }) + const attempted = await failure.getByRole('definition').first().textContent() + expect(attempted).toMatch(/^dsh\/[0-9a-f]{8}$/) + await failure.getByText('再次发送消息即可重试', { exact: true }).waitFor() + const option = page.locator('[data-forge-worktree]') + const checkbox = option.getByRole('checkbox', { name: '工作树', exact: true }) + const composer = page.locator('[data-composer-input]').first() + await page.locator('[data-composer-input][contenteditable="true"]').waitFor() + expect(await composer.textContent()).toBe(prompt) + expect(await checkbox.isChecked()).toBe(true) + expect(await checkbox.isDisabled()).toBe(false) + await option.getByRole('button', { name: '基于 release', exact: true }).waitFor() + // The card presents the refusal, so the composer adds no notice of its own. + expect(await page.getByRole('alert').filter({ hasText: 'could not be created' }).count()).toBe(0) + + // Sending again tries again with a new branch name. + await composer.press('Enter') + await expect.poll(() => failure.getByRole('definition').first().textContent(), { timeout: 20_000 }).not.toBe(attempted) + await page.locator('[data-composer-input][contenteditable="true"]').waitFor() + expect(await composer.textContent()).toBe(prompt) + + await card.getByRole('button', { name: '不使用工作树', exact: true }).click() + await card.waitFor({ state: 'detached' }) + await expect.poll(() => checkbox.isChecked()).toBe(false) + await composer.press('Enter') + const sessionId = await settled + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() + await card.waitFor({ state: 'detached' }) + + const session = scaffold.ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('The Session must stay loaded') + expect(scaffold.ctx.workingDirectory.get(session)).toBe(project) + const events = await readPersistedEvents(scaffold, sessionId) + expect(worktreeRecords(events)).toEqual([{ action: 'request', from: 'main' }, { action: 'request', from: 'release' }, { action: 'cancel' }]) + expect(events.filter(event => event.type === 'turn/start')).toHaveLength(1) + expect(events.some(event => event.type === 'user/message' && event.data.source.kind === 'working-directory')).toBe(false) + const label = page.locator('[data-forge-branch]') + await expect.poll(() => label.textContent(), { timeout: 20_000 }).toBe('main') + await label.hover() + await page.getByRole('tooltip').filter({ hasText: `当前检出:${project}` }).waitFor() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index df35c5e0e0..474b48f335 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -44,6 +44,7 @@ "tests/voice-input.e2e.ts", "tests/cot-translation.e2e.ts", "tests/cot-translation-paid.e2e.ts", + "tests/forge-panel.e2e.ts", "tests/voice-setup.e2e.ts", "tests/voice-download.e2e.ts", "tests/prefix-proxy.ts", diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 9162441ce9..ac1d7bfe81 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: cd68131cccc623de - zh: 9c977cdc8767be10 + en: 15ba282f07026c0b + zh: 73acd9f55122ee43 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 9002a66c72..ad09747cb5 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -33,7 +33,9 @@ flowchart LR svc_forge["ctx.forge
Code host change requests"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] svc_forgeSession["ctx.forgeSession
Session change request bindings"] + svc_forgePanel["ctx.forgePanel
Forge panel Remote"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -355,6 +357,7 @@ flowchart LR pkg_experimental_browser_use_stagehand_native --> svc_browserUse pkg_experimental_claude_code_mods --> svc_claudeCodeMods pkg_experimental_client_ui_cot_translation --> svc_cotTranslation + pkg_experimental_client_ui_forge --> svc_forgePanel pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse pkg_experimental_forge --> svc_forge @@ -507,7 +510,9 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_client_ui_forge svc_forge --> pkg_experimental_forge_session + svc_forgeSession --> pkg_experimental_client_ui_forge svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -640,8 +645,9 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | Fetches and validates the index sources the user configures; installation goes through the plugin manager. | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | The dsh launcher supplies data-only profile locations and composition inputs; reload scheduling belongs to dsh-hmr. | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | Owns browser authentication and shared HTTP request dispatch; API adapters register endpoints and streams. | -| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | -| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | - | - | A host-only projection derives bindings from shell results and binding records; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Providers map remote and change request URLs, look up change requests by head branch, and read status in batches; watchers of one change request share one polling loop. | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | A Session projection derives bindings from shell results and binding records and publishes their URLs without status to clients; push results trigger head-branch discovery, and Session watchers poll bound change requests through the forge registry. | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | Streams one panel state per followed Session: a read-only Git inspection in the Session sandbox, the recognized code host and its sign-in, and the bound change requests with polled status. | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | Explicit experimental creation from a pinned local commit. Existing write permissions govern checkout and shared Git metadata; the working-directory service owns the resulting Session directory. | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | One Session projection owns the effective execution directory. The filesystem validates changes; user context reports them while original metadata and write grants stay fixed. | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | Connection-owned providers serve shared resource tools in the calling agent scope. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8c926481d6..f75a102deb 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -35,7 +35,9 @@ flowchart LR svc_forge["ctx.forge
Code host change requests"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] svc_forgeSession["ctx.forgeSession
Session change request bindings"] + svc_forgePanel["ctx.forgePanel
Forge panel Remote"] pkg_experimental_worktree["experimental-worktree"] svc_worktrees["ctx.worktrees
Git worktree creation"] pkg_experimental_tool_worktree["experimental-tool-worktree"] @@ -357,6 +359,7 @@ flowchart LR pkg_experimental_browser_use_stagehand_native --> svc_browserUse pkg_experimental_claude_code_mods --> svc_claudeCodeMods pkg_experimental_client_ui_cot_translation --> svc_cotTranslation + pkg_experimental_client_ui_forge --> svc_forgePanel pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse pkg_experimental_computer_use_cua_driver_native --> svc_computerUse pkg_experimental_forge --> svc_forge @@ -509,7 +512,9 @@ flowchart LR svc_dynamicCordisRunner --> pkg_tool_cordis svc_fileReferences --> pkg_api_session_controller svc_fileUploads --> pkg_api_session_controller + svc_forge --> pkg_experimental_client_ui_forge svc_forge --> pkg_experimental_forge_session + svc_forgeSession --> pkg_experimental_client_ui_forge svc_fs --> pkg_tool_fs svc_headlessStartup --> pkg_headless svc_hmr --> pkg_app_boot @@ -642,8 +647,9 @@ flowchart LR | `ctx.pluginMarket` | `core` | [`plugin-market`](../packages/boot/plugin-market) | - | [`client-ui-plugin-market`](../packages/client/ui-plugin-market), [`plugin-market`](../packages/boot/plugin-market) | - | 获取并校验用户配置的索引源;安装通过插件管理器完成。 | | `ctx.profileContext` | `core` | [`app-boot`](../packages/boot/app-boot) | - | [`plugin-manager`](../packages/boot/plugin-manager) | - | dsh launcher 提供纯数据形式的 profile 位置与组合输入;重载调度由 dsh-hmr 负责。 | | `ctx.connection` | `core` | [`client-connection`](../packages/client/connection) | - | [`api-gateway`](../packages/api/gateway), [`host-frontend-static`](../packages/host/frontend-static) | - | 负责浏览器认证与共享 HTTP 请求分发;API 适配器注册端点和流。 | -| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | -| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | - | - | 仅宿主侧的投影从 shell 结果与绑定记录派生绑定;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | +| `ctx.forge` | `seam` | [`experimental-forge`](../packages/experimental/forge) | [`experimental-forge-github`](../packages/experimental/forge-github) | [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | 提供方映射远端与变更请求 URL、按 head 分支查找变更请求并批量读取状态;同一变更请求的监听方共享一个轮询循环。 | +| `ctx.forgeSession` | `service` | [`experimental-forge-session`](../packages/experimental/forge-session) | - | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | Session 投影从 shell 结果与绑定记录派生绑定,并把不含状态的 URL 发布给客户端;推送结果触发按 head 分支的发现,Session 监听方通过 forge 注册表轮询已绑定的变更请求。 | +| `ctx.forgePanel` | `service` | [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | - | - | - | 为每个被跟随的 Session 推送一份面板状态:在 Session 沙箱中进行的只读 Git 检查、识别出的代码托管平台及其登录状态,以及带轮询状态的已绑定变更请求。 | | `ctx.worktrees` | `seam` | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-worktree`](../packages/experimental/worktree) | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | - | 显式实验组合从固定的本地提交创建工作树。检出目录与共享 Git 元数据受现有写权限约束;工作目录服务拥有创建后的 Session 目录。 | | `ctx.workingDirectory` | `seam` | [`working-directory`](../packages/session/working-directory) | [`working-directory`](../packages/session/working-directory) | [`tool-working-directory`](../packages/session/tool-working-directory), [`tool-fs`](../packages/fs/tool-fs), [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`subagent`](../packages/subagent/subagent), [`sdk-jsonrpc-server`](../packages/sdk/server) | - | 一个 Session 投影拥有有效执行目录。文件系统验证变更,用户上下文报告目录信息,原始元数据与写入授权保持不变。 | | `ctx.mcpResources` | `seam` | [`mcp-resources`](../packages/mcp/mcp-resources) | [`mcp-client`](../packages/mcp/mcp-client) | [`mcp-resources`](../packages/mcp/mcp-resources) | - | 连接所有者提供的操作在调用 agent 的作用域内服务于共享资源工具。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6b96f3a40e..565aef147b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1010,6 +1010,35 @@ export interface CotTranslationPreferences { ``` + + + +## `@deepseek-ai/dsh-experimental-client-ui-forge` + +- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:178`](../packages/experimental/client-ui-forge/src/types.ts) + +```ts config-catalog +/** Host settings of the forge panel Remote. */ +export interface Config { + /** Git executable name or absolute path in the Session's execution world. */ + gitCommand?: string + /** Deadline in milliseconds for one Git command, including confinement. */ + gitTimeoutMs?: number + /** Termination grace period in milliseconds for each Git process. */ + graceMs?: number + /** Maximum captured bytes per Git output stream. */ + maxOutputBytes?: number + /** Deadline in milliseconds for one provider sign-in probe. */ + readinessTimeoutMs?: number + /** Most local branches listed as worktree bases. */ + maxBranches?: number + /** Prefix of the branch and checkout directory created for a Session worktree; eight random hexadecimal digits follow it. */ + worktreePrefix?: string +} +``` + + @@ -1054,7 +1083,7 @@ export type Config = CuaConfig ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:142`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:162`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1443,7 +1472,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' ## `@deepseek-ai/dsh-experimental-worktree` - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:20`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, and subprocess resource limits. */ @@ -4919,6 +4948,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | +| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 7bb196013a..f6029f247f 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1012,6 +1012,35 @@ export interface CotTranslationPreferences { ``` + + + +## `@deepseek-ai/dsh-experimental-client-ui-forge` + +- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:178`](../packages/experimental/client-ui-forge/src/types.ts) + +```ts config-catalog +/** Host settings of the forge panel Remote. */ +export interface Config { + /** Git executable name or absolute path in the Session's execution world. */ + gitCommand?: string + /** Deadline in milliseconds for one Git command, including confinement. */ + gitTimeoutMs?: number + /** Termination grace period in milliseconds for each Git process. */ + graceMs?: number + /** Maximum captured bytes per Git output stream. */ + maxOutputBytes?: number + /** Deadline in milliseconds for one provider sign-in probe. */ + readinessTimeoutMs?: number + /** Most local branches listed as worktree bases. */ + maxBranches?: number + /** Prefix of the branch and checkout directory created for a Session worktree; eight random hexadecimal digits follow it. */ + worktreePrefix?: string +} +``` + + @@ -1056,7 +1085,7 @@ export type Config = CuaConfig ## `@deepseek-ai/dsh-experimental-forge` -- `source`: [`packages/experimental/forge/src/types.ts:142`](../packages/experimental/forge/src/types.ts) +- `source`: [`packages/experimental/forge/src/types.ts:162`](../packages/experimental/forge/src/types.ts) ```ts config-catalog /** Poll cadence and fetch deadline for watched change requests. */ @@ -1445,7 +1474,7 @@ export type PaidTranslationProvider = 'deepseek-account' | 'deepseek-official' ## `@deepseek-ai/dsh-experimental-worktree` - `inject`: `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` -- `source`: [`packages/experimental/worktree/src/index.ts:19`](../packages/experimental/worktree/src/index.ts) +- `source`: [`packages/experimental/worktree/src/index.ts:20`](../packages/experimental/worktree/src/index.ts) ```ts config-catalog /** Worktree location, generated names, executables, and subprocess resource limits. */ @@ -4921,6 +4950,7 @@ export interface Config { | `@deepseek-ai/dsh-experimental-badge-skill-bundle` | — | [`packages/experimental/badge-skill-bundle/src/index.ts`](../packages/experimental/badge-skill-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-browser-use-runtime` | — | [`packages/experimental/browser-use-runtime/src/index.ts`](../packages/experimental/browser-use-runtime/src/index.ts) | | `@deepseek-ai/dsh-experimental-cot-translation-bundle` | — | [`packages/experimental/cot-translation-bundle/src/index.ts`](../packages/experimental/cot-translation-bundle/src/index.ts) | +| `@deepseek-ai/dsh-experimental-forge-bundle` | — | [`packages/experimental/forge-bundle/src/index.ts`](../packages/experimental/forge-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-inspector-profile` | — | [`packages/experimental/inspector-profile/src/index.ts`](../packages/experimental/inspector-profile/src/index.ts) | | `@deepseek-ai/dsh-experimental-plugin-market-bundle` | — | [`packages/experimental/plugin-market-bundle/src/index.ts`](../packages/experimental/plugin-market-bundle/src/index.ts) | | `@deepseek-ai/dsh-experimental-ralph-bundle` | — | [`packages/experimental/ralph-bundle/src/index.ts`](../packages/experimental/ralph-bundle/src/index.ts) | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1d6e8423b4..cab9074f0a 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - | +| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | `client-ui-forge` | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | @@ -18,7 +18,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`compaction-basic`](../packages/compaction/compaction-basic), [`webhook`](../packages/experimental/webhook) | | `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`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | @@ -66,7 +66,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -102,7 +102,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 7faa56c644..53d25df43c 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - | +| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | `client-ui-forge` | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | @@ -20,7 +20,7 @@ | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) | | `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) | | `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`compaction-basic`](../packages/compaction/compaction-basic), [`webhook`](../packages/experimental/webhook) | | `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`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | @@ -68,7 +68,7 @@ | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, `client-ui-forge`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, `forge-session`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, [`ptc-runtime-node`](../packages/ptc-runtime/ptc-runtime-node), `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | @@ -104,7 +104,7 @@ | `internal/config` | [`config-editor`](../packages/boot/config-editor) (`waterfall`) | [`llm-pi-ai`](../packages/llm/llm-pi-ai) | | `internal/dispatch` | - | [`terminal-bash`](../packages/terminal/terminal-bash) | | `internal/plugin` | - | `computer-use-cua-driver-native`, `inspector`, `loader`, [`lsp-stdio`](../packages/lsp/lsp-stdio), [`mcp-client`](../packages/mcp/mcp-client), `modules` | -| `internal/service` | - | `gateway` | +| `internal/service` | - | `client-ui-forge`, `gateway` | | `internal/status` | - | [`agent`](../packages/core/agent), `inspector`, `translator`, [`web`](../packages/web/web) | | `internal/update` | - | [`app-boot`](../packages/boot/app-boot) | | `loader/volatile-update` | - | [`cua`](../packages/experimental/cua), [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` | diff --git a/docs/experimental-persistence-catalog.i18n.yaml b/docs/experimental-persistence-catalog.i18n.yaml index 1511a6adc2..4d30b1d272 100644 --- a/docs/experimental-persistence-catalog.i18n.yaml +++ b/docs/experimental-persistence-catalog.i18n.yaml @@ -6,5 +6,5 @@ en: a11f106978c9dce7 zh: a11f106978c9dce7 /experimental-plugin-record-catalog: - en: 90e85bc15ccdeb75 - zh: 3fd9e6d80b2bc689 + en: 3df60f36caaca677 + zh: e587f87d2a0dbc48 diff --git a/docs/experimental-persistence-catalog.md b/docs/experimental-persistence-catalog.md index 245685d8e4..2b43495e23 100644 --- a/docs/experimental-persistence-catalog.md +++ b/docs/experimental-persistence-catalog.md @@ -13,6 +13,7 @@ Shared Session event declarations and resolved schemas appear in the [Session pe | Record | Owner | Purpose | Payload and source | |---|---|---|---| +| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | WorktreeRecord · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) | | `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/experimental-persistence-catalog.zh.md b/docs/experimental-persistence-catalog.zh.md index d9a22569e6..34f49c7195 100644 --- a/docs/experimental-persistence-catalog.zh.md +++ b/docs/experimental-persistence-catalog.zh.md @@ -13,6 +13,7 @@ | 记录 | 所属包 | 用途 | 载荷与源码 | |---|---|---|---| +| `plugin:client-ui-forge/worktree` | `@deepseek-ai/dsh-experimental-client-ui-forge` | A request, withdrawal, or creation of the worktree a Session starts in. | WorktreeRecord · [`packages/experimental/client-ui-forge/src/types.ts`](../packages/experimental/client-ui-forge/src/types.ts) | | `plugin:forge-session/binding` | `@deepseek-ai/dsh-experimental-forge-session` | A caller or discovery action that binds, restores, or hides one change request. | BindingRecord · [`packages/experimental/forge-session/src/types.ts`](../packages/experimental/forge-session/src/types.ts) | | `plugin:translator/request` | `@deepseek-ai/dsh-experimental-translator` | Provider-independent translation input, retained before dispatch. | TranslationRequestRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | | `plugin:translator/result` | `@deepseek-ai/dsh-experimental-translator` | Successful translation, retained before it is returned to the consumer. | TranslationResultRecord · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) | diff --git a/docs/module-graph.md b/docs/module-graph.md index 6e6a0c340e..2f115877fe 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -263,12 +263,14 @@ flowchart TD pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_bundle["experimental-forge-bundle"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] @@ -933,6 +935,19 @@ flowchart TD pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt pkg_experimental_browser_use_stagehand_native --> pkg_tools + pkg_experimental_client_ui_forge --> pkg_agent + pkg_experimental_client_ui_forge --> pkg_agent_controller + pkg_experimental_client_ui_forge --> pkg_experimental_forge + pkg_experimental_client_ui_forge --> pkg_experimental_forge_session + pkg_experimental_client_ui_forge --> pkg_experimental_worktree + pkg_experimental_client_ui_forge --> pkg_fs + pkg_experimental_client_ui_forge --> pkg_sandbox + pkg_experimental_client_ui_forge --> pkg_sandbox_policy + pkg_experimental_client_ui_forge --> pkg_session + pkg_experimental_client_ui_forge --> pkg_session_projection + pkg_experimental_client_ui_forge --> pkg_subprocess + pkg_experimental_client_ui_forge --> pkg_typert_protocol + pkg_experimental_client_ui_forge --> pkg_working_directory pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools @@ -1554,6 +1569,7 @@ flowchart TD | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | +| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | @@ -1719,6 +1735,7 @@ flowchart TD | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`experimental-forge`](../packages/experimental/forge), [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`typert-protocol`](../packages/typert/protocol), [`working-directory`](../packages/session/working-directory) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8430790bdf..e569e3df31 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -265,12 +265,14 @@ flowchart TD pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"] pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"] pkg_experimental_client_ui_cot_translation["experimental-client-ui-cot-translation"] + pkg_experimental_client_ui_forge["experimental-client-ui-forge"] pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"] pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"] pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"] pkg_experimental_cot_translation_bundle["experimental-cot-translation-bundle"] pkg_experimental_cua_macos["experimental-cua-macos"] pkg_experimental_forge["experimental-forge"] + pkg_experimental_forge_bundle["experimental-forge-bundle"] pkg_experimental_forge_github["experimental-forge-github"] pkg_experimental_forge_session["experimental-forge-session"] pkg_experimental_inspector["experimental-inspector"] @@ -935,6 +937,19 @@ flowchart TD pkg_experimental_browser_use_stagehand_native --> pkg_browser_use pkg_experimental_browser_use_stagehand_native --> pkg_system_prompt pkg_experimental_browser_use_stagehand_native --> pkg_tools + pkg_experimental_client_ui_forge --> pkg_agent + pkg_experimental_client_ui_forge --> pkg_agent_controller + pkg_experimental_client_ui_forge --> pkg_experimental_forge + pkg_experimental_client_ui_forge --> pkg_experimental_forge_session + pkg_experimental_client_ui_forge --> pkg_experimental_worktree + pkg_experimental_client_ui_forge --> pkg_fs + pkg_experimental_client_ui_forge --> pkg_sandbox + pkg_experimental_client_ui_forge --> pkg_sandbox_policy + pkg_experimental_client_ui_forge --> pkg_session + pkg_experimental_client_ui_forge --> pkg_session_projection + pkg_experimental_client_ui_forge --> pkg_subprocess + pkg_experimental_client_ui_forge --> pkg_typert_protocol + pkg_experimental_client_ui_forge --> pkg_working_directory pkg_experimental_computer_use_cua_driver_native --> pkg_computer_use pkg_experimental_computer_use_cua_driver_native --> pkg_system_prompt pkg_experimental_computer_use_cua_driver_native --> pkg_tools @@ -1556,6 +1571,7 @@ flowchart TD | [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | — | | [`experimental-cot-translation-bundle`](../packages/experimental/cot-translation-bundle) | `experimental` | — | | [`experimental-forge`](../packages/experimental/forge) | `experimental` | — | +| [`experimental-forge-bundle`](../packages/experimental/forge-bundle) | `experimental` | — | | [`experimental-inspector`](../packages/experimental/inspector) | `experimental` | — | | [`experimental-inspector-profile`](../packages/experimental/inspector-profile) | `experimental` | — | | [`experimental-plugin-market-bundle`](../packages/experimental/plugin-market-bundle) | `experimental` | — | @@ -1721,6 +1737,7 @@ flowchart TD | [`experimental-browser-use-chrome-devtools-mcp`](../packages/experimental/browser-use-chrome-devtools-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-playwright-mcp`](../packages/experimental/browser-use-playwright-mcp) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) | | [`experimental-browser-use-stagehand-native`](../packages/experimental/browser-use-stagehand-native) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | +| [`experimental-client-ui-forge`](../packages/experimental/client-ui-forge) | `experimental` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`experimental-forge`](../packages/experimental/forge), [`experimental-forge-session`](../packages/experimental/forge-session), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`typert-protocol`](../packages/typert/protocol), [`working-directory`](../packages/session/working-directory) | | [`experimental-computer-use-cua-driver-native`](../packages/experimental/computer-use-cua-driver-native) | `experimental` | [`computer-use`](../packages/computer-use/computer-use), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`experimental-cua-macos`](../packages/experimental/cua-macos) | `experimental` | [`agent`](../packages/core/agent), [`computer-use`](../packages/computer-use/computer-use), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) | | [`experimental-tool-worktree`](../packages/experimental/tool-worktree) | `experimental` | [`experimental-worktree`](../packages/experimental/worktree), [`tools`](../packages/core/tools) | diff --git a/docs/subsystems/forge.i18n.yaml b/docs/subsystems/forge.i18n.yaml index 0555d82c9f..55eaa139b9 100644 --- a/docs/subsystems/forge.i18n.yaml +++ b/docs/subsystems/forge.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/forge.md /forges: - en: 416233808b0653c3 - zh: 22fbac4b46e741ad + en: 1b51f9d385dafe76 + zh: 4bb73627aa69d652 /forges/providers-and-change-requests: en: 4ec7d1892b4067db zh: 6bb70d21ae3d7a89 @@ -12,5 +12,8 @@ en: 26ccfa2e342e3e67 zh: fe6cc2a622757c48 /forges/session-bindings: - en: 036a714f103fafd8 - zh: aa11325be880025d + en: ad28bdce5c5d4d49 + zh: 1acd042350634970 +/forges/panel: + en: 76bc9afcf36e3df2 + zh: 673b61562b150c95 diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 8dc217841d..c66b1f5cd1 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -2,7 +2,7 @@ English | [中文](forge.zh.md) -The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`. All three load only through explicit composition. +The experimental forge capability seam reads change requests, meaning pull requests and merge requests, from code hosts. [`dsh-experimental-forge`](../../packages/experimental/forge/README.md) is the Service Definition and provides `ctx.forge`; [`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.md) is the Service Provider for GitHub hosts; [`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.md) is the Consumer that binds change requests to Sessions and provides `ctx.forgeSession`; [`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.md) is the GUI Consumer that provides `ctx.forgePanel`. The default-disabled [forge bundle](../../packages/experimental/forge-bundle/README.md) composes all four, with an isolated [worktree service](../../packages/experimental/worktree/README.md) for the panel, from the Plugins page; otherwise they load only through explicit composition. ## Providers and change requests @@ -53,6 +53,18 @@ interface ForgeProvider { ): Promise> /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + /** Report whether the provider can reach `host` with current credentials. */ + readiness(host: string, signal: AbortSignal): Promise +} +``` + +```ts type-equiv +/** Whether a provider can reach one host with the current credentials. */ +interface ForgeReadiness { + /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ + readonly state: 'ready' | 'unauthenticated' | 'unavailable' + /** Human-readable detail, or null when ready. */ + readonly message: string | null } ``` @@ -81,6 +93,10 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** Lines the change request adds. */ + readonly additions: number + /** Lines the change request deletes. */ + readonly deletions: number /** Merge conflict status; `unknown` while the host computes it. */ readonly mergeability: Mergeability /** Aggregate review outcome. */ @@ -95,6 +111,8 @@ interface ChangeRequestSnapshot { readonly unresolvedThreads: number /** ISO 8601 time of the host's last update to the change request. */ readonly updatedAt: string + /** ISO 8601 time the change request was merged or closed, or null while it is open. */ + readonly closedAt: string | null } ``` @@ -124,7 +142,7 @@ type ChangeRequestListener = (update: ChangeRequestUpdate) => void ## Session bindings -`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, and `git push` results trigger head-branch discovery through `ctx.forge`. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. +`ctx.forgeSession` derives each Session's bindings from its own log: `gh pr create` results bind the URLs they print, and `git push` results trigger head-branch discovery through `ctx.forge`. Track, dismiss, and discovery actions are `plugin:forge-session/binding` records, which stay outside the model-visible conversation. The `forgeChangeRequests` projection stays on the Host. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules. ```ts type-equiv /** @@ -168,6 +186,188 @@ interface SessionChangeRequest { type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void ``` +## Panel + +`ctx.forgePanel` is the Host Remote of the [forge panel](../../packages/experimental/client-ui-forge/README.md#use-this-package). Following a Session streams a `ForgePanelState`: a read-only Git inspection of the Session's current directory in its execution world, the code host recognized from the upstream remote or `origin`, the sign-in state of that host, the Session's bindings with their polled status and last snapshot, and its worktree request. Its actions list base branches, bind and hide URLs, refresh, request or withdraw a worktree, stop a running creation, and restore a removed worktree or leave it for the source checkout. A request is a `plugin:client-ui-forge/worktree` record. The panel listens to [Agent admission](../../packages/core/agent/README.md): it holds the first message of a Session with an open request while it creates the worktree through `ctx.worktrees`, refuses messages while the worktree the Session works in is gone, and keeps the last failed creation in the panel state. Each refusal carries a `ForgeAdmissionRefusal` reason, and the panel presents it while it follows the Session. The first step proposal creates the worktree for a message that entered without admission. Requests after the first turn starts are ignored. Changes to a Session's checkout run as maintenance of its Agent, so a message from another client waits for them. + +```ts type-equiv +/** Git facts for the checkout that contains the Session's current directory. */ +interface ForgeCheckout { + /** Top-level directory of the checkout. */ + readonly root: string + /** Checked-out branch, or null when HEAD is detached. */ + readonly branch: string | null +} +``` + +```ts type-equiv +/** Inspection result for the Session's current directory. */ +type ForgeCheckoutState = + /** The first inspection has not finished. */ + | { readonly state: 'pending' } + /** The current directory is not inside a Git checkout. */ + | { readonly state: 'none' } + /** Git could not run or reported an error. */ + | { readonly state: 'failed'; readonly message: string } + /** The checkout facts. */ + | { readonly state: 'ready'; readonly checkout: ForgeCheckout } +``` + +```ts type-equiv +/** The worktree the panel created and entered for a Session before its first step. */ +interface ForgeSessionWorktree { + /** Canonical checkout directory. */ + readonly path: string + /** Branch created for the checkout. */ + readonly branch: string + /** Requested base branch, or null when the checkout started from HEAD of the source checkout. */ + readonly from: string | null + /** Commit object name the branch started from. */ + readonly baseCommit: string + /** Canonical root of the source checkout. */ + readonly repositoryRoot: string +} +``` + +```ts type-equiv +/** Payload of the `plugin:client-ui-forge/worktree` record. */ +type WorktreeRecord = + /** Create a worktree before the Session's first step, starting from `from`, or from HEAD when it is null. */ + | { readonly action: 'request'; readonly from: string | null } + /** Withdraw the open request. */ + | { readonly action: 'cancel' } + /** The requested worktree was created and became the Session's working directory. */ + | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } +``` + +```ts type-equiv +/** Why the Session's checkout cannot host a new worktree. */ +type ForgeWorktreeBlocker = + /** The current directory is not inside a Git checkout. */ + | 'not-git' + /** The Session's sandbox cannot write the checkout or its Git directory, as for a linked worktree outside the writable root. */ + | 'outside-writable-root' + /** The Session runs read-only. */ + | 'read-only' +``` + +```ts type-equiv +/** One running worktree creation. */ +interface ForgeWorktreeCreation { + /** Running step: `prepare` validates and creates the branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' + /** Branch the attempt creates. */ + readonly branch: string + /** Base branch, or null for HEAD. */ + readonly from: string | null + /** + * Whether a message waits for the creation before it enters the Session. + * Only such a creation can be stopped, which returns the message to its + * sender; a creation at the first step serves a message the Session already holds. + */ + readonly held: boolean +} +``` + +```ts type-equiv +/** The last attempt to create the requested worktree for a message, which failed. */ +interface ForgeWorktreeFailure { + /** Branch the attempt created, or null when it failed before naming one. */ + readonly branch: string | null + /** Base branch of the attempt, or null for HEAD. */ + readonly from: string | null + /** Step that failed. */ + readonly phase: 'prepare' | 'checkout' + /** Failure text. */ + readonly message: string +} +``` + +```ts type-equiv +/** + * Why the panel refuses a message at Agent admission; the refusal reason a + * `session/prompt-rejected` error carries. The panel shows each refusal itself + * while it follows the Session. + */ +type ForgeAdmissionRefusal = + /** The requested worktree could not be created; the panel state carries the failure. */ + | 'forge/worktree-creation-failed' + /** The creation the message waited for was stopped. */ + | 'forge/worktree-creation-stopped' + /** The worktree the Session works in was removed. */ + | 'forge/worktree-missing' +``` + +```ts type-equiv +/** The worktree a Session works in, whose directory no longer exists. */ +interface ForgeMissingWorktree { + /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ + readonly branchExists: boolean +} +``` + +```ts type-equiv +/** Worktree facts of one Session in the panel state. */ +interface ForgePanelWorktree { + /** + * Whether the Session can still request or withdraw a worktree: a worktree + * service is mounted, no turn has started, and none was created. + */ + readonly editable: boolean + /** Why the checkout cannot host a worktree, or null when it can, before the first inspection, and after a failed one. */ + readonly blocker: ForgeWorktreeBlocker | null + /** Open request, or null without one. */ + readonly request: ForgeWorktreeRequest | null + /** Created worktree, or null before creation. */ + readonly created: ForgeSessionWorktree | null + /** Running creation, or null. */ + readonly creation: ForgeWorktreeCreation | null + /** + * The failed attempt that returned the last message to its sender, or null. + * It clears when another attempt starts and when the request changes or is withdrawn. + */ + readonly failure: ForgeWorktreeFailure | null + /** The created worktree the Session works in when its directory is gone, or null. */ + readonly missing: ForgeMissingWorktree | null +} +``` + +```ts type-equiv +/** One bound change request in the panel state. */ +interface ForgePanelChangeRequest extends SessionChangeRequest { + /** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */ + readonly snapshot: ChangeRequestSnapshot | null +} +``` + +```ts type-equiv +/** Everything the panel shows for one Session. */ +interface ForgePanelState { + /** Current working directory of the Session. */ + readonly directory: string + /** Inspection result for `directory`. */ + readonly checkout: ForgeCheckoutState + /** Code host repository of the checkout's upstream remote or `origin`, or null when no provider serves them. */ + readonly repository: ForgeRepository | null + /** Sign-in state for the repository's host, or null before the first probe and without a repository. */ + readonly readiness: ForgeReadiness | null + /** Visible change request bindings with their latest polling results, in first-recorded order. */ + readonly changeRequests: readonly ForgePanelChangeRequest[] + /** Worktree request and creation state. */ + readonly worktree: ForgePanelWorktree +} +``` + +```ts type-equiv +/** Local branches offered as the base of a new worktree. */ +interface ForgeBranches { + /** Checked-out branch, or null when HEAD is detached. */ + readonly current: string | null + /** Local branch names, `current` first and then most recently committed first, at most `maxBranches`. */ + readonly branches: readonly string[] +} +``` + @@ -225,6 +425,15 @@ changeRequestUrl(ref: ChangeRequestRef): string */ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +/** + * Report whether the provider can reach the repository's host with current credentials. + * @param repository - repository served by a registered provider. + * @param signal - cancellation for the probe. + * @returns the provider's readiness for the host. + * @throws when no provider serves the repository's forge. + */ +async readiness(repository: ForgeRepository, signal: AbortSignal): Promise + /** * Watch one change request as an effect on the calling fiber. The first * watcher starts polling at once; listeners receive every later result, @@ -236,6 +445,14 @@ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSigna */ watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void +/** + * Poll a watched change request at once, including after a terminal state. + * A fetch already in flight for its forge serves the request; an unwatched + * change request is unchanged. + * @param ref - change request to poll. + */ +refresh(ref: ChangeRequestRef): void + /** * Read the latest polling result of a watched change request. * @param ref - change request to read. @@ -246,6 +463,121 @@ latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + +### `ctx.forgePanel` — `ForgePanelController` + +Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it: at Agent admission of the Session's first message, which waits for the worktree, or at the first step proposal of a message that entered without admission. A failed or stopped creation returns the message to its sender. Admission also refuses messages while the worktree the Session works in is gone; that worktree can be checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. + +```ts cordis-catalog +/** + * Stream the panel state of a Session: the current state first, then each + * later state. Intermediate states may be skipped when the reader is slower + * than the updates. The stream ends when `signal` aborts or the Remote stops. + * @param session - Session to follow. + * @param signal - ends the stream. + * @returns panel states in publication order. + */ +@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable + +/** + * List the local branches that can start a worktree of the Session's current checkout. + * @param session - Session whose current directory is listed. + * @param signal - request cancellation. + * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first. + * @throws when the current directory is not inside a checkout or Git fails. + */ +@Remote async branches(session: Session, signal: AbortSignal): Promise + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL. + */ +@Remote async track(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Hide a visible binding of a Session; other URLs leave the Session unchanged. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + */ +@Remote async dismiss(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Reinspect a followed Session's checkout, probe sign-in again, and poll its + * change requests at once; an unfollowed Session is unchanged. + * @param session - Session whose panel refreshes. + * @param signal - request cancellation. + */ +@Remote async refresh(session: Session, signal: AbortSignal): Promise + +/** + * Ask for a new worktree before the Session's first turn, replacing an open + * request and forgetting a failed attempt. Admission of the next message, or + * else the first step proposal, creates a branch from `from`, or from HEAD of + * the current checkout when it is null, and enters its checkout. + * @param session - Session without a started turn. + * @param from - local base branch, or null for HEAD. + * @param signal - request cancellation. + * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was + * created; `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise + +/** + * Withdraw the open worktree request of a Session with its failed attempt; without a request the Session is unchanged. + * @param session - Session without a started turn. + * @param signal - request cancellation. + * @throws `forgePanel/worktrees-unavailable` without a worktree service; `forgePanel/session-started` + * after a turn started or the worktree was created; `forgePanel/session-busy` while the worktree is + * being created. + */ +@Remote async cancelWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Stop the creation that holds a message of the Session at admission; the + * message goes back to its sender and the request stays open. Without such a + * creation the Session is unchanged. + * @param session - Session whose creation stops. + * @param signal - request cancellation. + */ +@Remote async stopCreation(session: Session, signal: AbortSignal): Promise + +/** + * Check out the created worktree of a Session again at its recorded + * directory when that directory is gone: its branch when the branch still + * exists, otherwise a new branch of the same name at the base commit. Changes + * that were never committed are not recovered. Runs as Agent maintenance; + * without a missing directory the Session is unchanged. + * @param session - Session whose worktree directory is gone. + * @param signal - request cancellation, which aborts the restoration. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or + * has other work; `forgePanel/restore-failed` when Git fails. + */ +@Remote async restoreWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Move a Session from its created worktree to the source checkout the + * worktree came from; the Session no longer works in the worktree. Runs as + * Agent maintenance. + * @param session - Session with a created worktree. + * @param signal - request cancellation. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the + * Session's Agent is not loaded or has other work; the working-directory service's error when the + * source checkout no longer exists. + */ +@Remote async continueInProject(session: Session, signal: AbortSignal): Promise +``` + +Types: [Session](session.md) + +Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts) + ### `ctx.forgeSession` — `ForgeSessionService` diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index 28b7d1d782..33d49f215e 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -2,7 +2,7 @@ [English](forge.md) | 中文 -实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`。三者都只能通过显式组合加载。 +实验性的 forge 能力 seam 从代码托管平台读取变更请求(change request,即 pull request 与 merge request)。[`dsh-experimental-forge`](../../packages/experimental/forge/README.zh.md) 是 Service Definition,提供 `ctx.forge`;[`dsh-experimental-forge-github`](../../packages/experimental/forge-github/README.zh.md) 是服务 GitHub 主机的 Service Provider;[`dsh-experimental-forge-session`](../../packages/experimental/forge-session/README.zh.md) 是把变更请求绑定到 Session 的 Consumer,提供 `ctx.forgeSession`;[`dsh-experimental-client-ui-forge`](../../packages/experimental/client-ui-forge/README.zh.md) 是提供 `ctx.forgePanel` 的 GUI Consumer。默认禁用的 [forge Bundle](../../packages/experimental/forge-bundle/README.zh.md) 可从插件页面组合这四个包,并为面板附带一个隔离的 [worktree 服务](../../packages/experimental/worktree/README.zh.md);除此之外,它们只能通过显式组合加载。 ## 提供方与变更请求 @@ -53,6 +53,18 @@ interface ForgeProvider { ): Promise> /** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */ findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise + /** Report whether the provider can reach `host` with current credentials. */ + readiness(host: string, signal: AbortSignal): Promise +} +``` + +```ts type-equiv +/** Whether a provider can reach one host with the current credentials. */ +interface ForgeReadiness { + /** `ready`, `unauthenticated` (sign-in needed), or `unavailable` (tool missing or host unreachable). */ + readonly state: 'ready' | 'unauthenticated' | 'unavailable' + /** Human-readable detail, or null when ready. */ + readonly message: string | null } ``` @@ -81,6 +93,10 @@ interface ChangeRequestSnapshot { readonly headSha: string /** Branch the change request merges into. */ readonly baseBranch: string + /** Lines the change request adds. */ + readonly additions: number + /** Lines the change request deletes. */ + readonly deletions: number /** Merge conflict status; `unknown` while the host computes it. */ readonly mergeability: Mergeability /** Aggregate review outcome. */ @@ -95,6 +111,8 @@ interface ChangeRequestSnapshot { readonly unresolvedThreads: number /** ISO 8601 time of the host's last update to the change request. */ readonly updatedAt: string + /** ISO 8601 time the change request was merged or closed, or null while it is open. */ + readonly closedAt: string | null } ``` @@ -124,7 +142,7 @@ type ChangeRequestListener = (update: ChangeRequestUpdate) => void ## Session 绑定 -`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 +`ctx.forgeSession` 从每个 Session 自己的日志派生其绑定:`gh pr create` 的结果绑定其输出的 URL,`git push` 的结果通过 `ctx.forge` 触发按 head 分支的发现。track、dismiss 与发现操作都是 `plugin:forge-session/binding` 插件记录,位于模型可见对话之外。`forgeChangeRequests` 投影只位于宿主侧。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。 ```ts type-equiv /** @@ -168,6 +186,188 @@ interface SessionChangeRequest { type SessionChangeRequestsListener = (changeRequests: readonly SessionChangeRequest[]) => void ``` +## 面板 + +`ctx.forgePanel` 是 [forge 面板](../../packages/experimental/client-ui-forge/README.zh.md#use-this-package)的宿主 Remote。跟随一个 Session 时,它以流的形式推送 `ForgePanelState`:在 Session 执行环境中对其当前目录进行的只读 Git 检查、从上游远端或 `origin` 识别出的代码托管平台、该平台的登录状态、Session 的绑定及其轮询状态与最近的快照,以及它的 worktree 请求。它的操作可以列出基准分支、绑定和隐藏 URL、刷新、请求或撤回 worktree、停止正在进行的创建,以及恢复已删除的 worktree 或离开它回到源检出。请求是一条 `plugin:client-ui-forge/worktree` 记录。面板监听 [Agent 消息接纳](../../packages/core/agent/README.zh.md):Session 有未完成的请求时,它在通过 `ctx.worktrees` 创建 worktree 期间暂留第一条消息;Session 所在的 worktree 已被删除时拒绝消息;最近一次失败的创建保留在面板状态中。每次拒绝都带有一个 `ForgeAdmissionRefusal` 原因,面板跟随该 Session 时由它自行呈现。对于未经接纳就进入的消息,由第一个步骤提议创建 worktree。第一轮开始之后的请求会被忽略。对 Session 检出的改动以其 Agent 的维护任务运行,因此其他客户端发来的消息会等它们完成。 + +```ts type-equiv +/** Git facts for the checkout that contains the Session's current directory. */ +interface ForgeCheckout { + /** Top-level directory of the checkout. */ + readonly root: string + /** Checked-out branch, or null when HEAD is detached. */ + readonly branch: string | null +} +``` + +```ts type-equiv +/** Inspection result for the Session's current directory. */ +type ForgeCheckoutState = + /** The first inspection has not finished. */ + | { readonly state: 'pending' } + /** The current directory is not inside a Git checkout. */ + | { readonly state: 'none' } + /** Git could not run or reported an error. */ + | { readonly state: 'failed'; readonly message: string } + /** The checkout facts. */ + | { readonly state: 'ready'; readonly checkout: ForgeCheckout } +``` + +```ts type-equiv +/** The worktree the panel created and entered for a Session before its first step. */ +interface ForgeSessionWorktree { + /** Canonical checkout directory. */ + readonly path: string + /** Branch created for the checkout. */ + readonly branch: string + /** Requested base branch, or null when the checkout started from HEAD of the source checkout. */ + readonly from: string | null + /** Commit object name the branch started from. */ + readonly baseCommit: string + /** Canonical root of the source checkout. */ + readonly repositoryRoot: string +} +``` + +```ts type-equiv +/** Payload of the `plugin:client-ui-forge/worktree` record. */ +type WorktreeRecord = + /** Create a worktree before the Session's first step, starting from `from`, or from HEAD when it is null. */ + | { readonly action: 'request'; readonly from: string | null } + /** Withdraw the open request. */ + | { readonly action: 'cancel' } + /** The requested worktree was created and became the Session's working directory. */ + | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } +``` + +```ts type-equiv +/** Why the Session's checkout cannot host a new worktree. */ +type ForgeWorktreeBlocker = + /** The current directory is not inside a Git checkout. */ + | 'not-git' + /** The Session's sandbox cannot write the checkout or its Git directory, as for a linked worktree outside the writable root. */ + | 'outside-writable-root' + /** The Session runs read-only. */ + | 'read-only' +``` + +```ts type-equiv +/** One running worktree creation. */ +interface ForgeWorktreeCreation { + /** Running step: `prepare` validates and creates the branch, then `checkout` writes the files. */ + readonly phase: 'prepare' | 'checkout' + /** Branch the attempt creates. */ + readonly branch: string + /** Base branch, or null for HEAD. */ + readonly from: string | null + /** + * Whether a message waits for the creation before it enters the Session. + * Only such a creation can be stopped, which returns the message to its + * sender; a creation at the first step serves a message the Session already holds. + */ + readonly held: boolean +} +``` + +```ts type-equiv +/** The last attempt to create the requested worktree for a message, which failed. */ +interface ForgeWorktreeFailure { + /** Branch the attempt created, or null when it failed before naming one. */ + readonly branch: string | null + /** Base branch of the attempt, or null for HEAD. */ + readonly from: string | null + /** Step that failed. */ + readonly phase: 'prepare' | 'checkout' + /** Failure text. */ + readonly message: string +} +``` + +```ts type-equiv +/** + * Why the panel refuses a message at Agent admission; the refusal reason a + * `session/prompt-rejected` error carries. The panel shows each refusal itself + * while it follows the Session. + */ +type ForgeAdmissionRefusal = + /** The requested worktree could not be created; the panel state carries the failure. */ + | 'forge/worktree-creation-failed' + /** The creation the message waited for was stopped. */ + | 'forge/worktree-creation-stopped' + /** The worktree the Session works in was removed. */ + | 'forge/worktree-missing' +``` + +```ts type-equiv +/** The worktree a Session works in, whose directory no longer exists. */ +interface ForgeMissingWorktree { + /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ + readonly branchExists: boolean +} +``` + +```ts type-equiv +/** Worktree facts of one Session in the panel state. */ +interface ForgePanelWorktree { + /** + * Whether the Session can still request or withdraw a worktree: a worktree + * service is mounted, no turn has started, and none was created. + */ + readonly editable: boolean + /** Why the checkout cannot host a worktree, or null when it can, before the first inspection, and after a failed one. */ + readonly blocker: ForgeWorktreeBlocker | null + /** Open request, or null without one. */ + readonly request: ForgeWorktreeRequest | null + /** Created worktree, or null before creation. */ + readonly created: ForgeSessionWorktree | null + /** Running creation, or null. */ + readonly creation: ForgeWorktreeCreation | null + /** + * The failed attempt that returned the last message to its sender, or null. + * It clears when another attempt starts and when the request changes or is withdrawn. + */ + readonly failure: ForgeWorktreeFailure | null + /** The created worktree the Session works in when its directory is gone, or null. */ + readonly missing: ForgeMissingWorktree | null +} +``` + +```ts type-equiv +/** One bound change request in the panel state. */ +interface ForgePanelChangeRequest extends SessionChangeRequest { + /** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */ + readonly snapshot: ChangeRequestSnapshot | null +} +``` + +```ts type-equiv +/** Everything the panel shows for one Session. */ +interface ForgePanelState { + /** Current working directory of the Session. */ + readonly directory: string + /** Inspection result for `directory`. */ + readonly checkout: ForgeCheckoutState + /** Code host repository of the checkout's upstream remote or `origin`, or null when no provider serves them. */ + readonly repository: ForgeRepository | null + /** Sign-in state for the repository's host, or null before the first probe and without a repository. */ + readonly readiness: ForgeReadiness | null + /** Visible change request bindings with their latest polling results, in first-recorded order. */ + readonly changeRequests: readonly ForgePanelChangeRequest[] + /** Worktree request and creation state. */ + readonly worktree: ForgePanelWorktree +} +``` + +```ts type-equiv +/** Local branches offered as the base of a new worktree. */ +interface ForgeBranches { + /** Checked-out branch, or null when HEAD is detached. */ + readonly current: string | null + /** Local branch names, `current` first and then most recently committed first, at most `maxBranches`. */ + readonly branches: readonly string[] +} +``` + @@ -225,6 +425,15 @@ changeRequestUrl(ref: ChangeRequestRef): string */ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise +/** + * Report whether the provider can reach the repository's host with current credentials. + * @param repository - repository served by a registered provider. + * @param signal - cancellation for the probe. + * @returns the provider's readiness for the host. + * @throws when no provider serves the repository's forge. + */ +async readiness(repository: ForgeRepository, signal: AbortSignal): Promise + /** * Watch one change request as an effect on the calling fiber. The first * watcher starts polling at once; listeners receive every later result, @@ -236,6 +445,14 @@ async findByHead(repository: ForgeRepository, branch: string, signal: AbortSigna */ watch(ref: ChangeRequestRef, listener: ChangeRequestListener): () => void +/** + * Poll a watched change request at once, including after a terminal state. + * A fetch already in flight for its forge serves the request; an unwatched + * change request is unchanged. + * @param ref - change request to poll. + */ +refresh(ref: ChangeRequestRef): void + /** * Read the latest polling result of a watched change request. * @param ref - change request to read. @@ -246,6 +463,121 @@ latest(ref: ChangeRequestRef): ChangeRequestUpdate | undefined Source: [`packages/experimental/forge/src/index.ts`](../../packages/experimental/forge/src/index.ts) + + +### `ctx.forgePanel` — `ForgePanelController` + +Serves the forge panel. Following a Session inspects the checkout of its current directory read-only in the Session's execution world, recognizes the code host of the upstream remote or `origin`, probes sign-in for that host, and polls the Session's bound change requests through `ctx.forge` until the last follower of that Session leaves. A worktree request makes the panel create a branch and checkout through an optional `ctx.worktrees` and enter it: at Agent admission of the Session's first message, which waits for the worktree, or at the first step proposal of a message that entered without admission. A failed or stopped creation returns the message to its sender. Admission also refuses messages while the worktree the Session works in is gone; that worktree can be checked out again or left for the source checkout. Changes to a Session's checkout run as Agent maintenance, so a message that arrives meanwhile waits for them. + +```ts cordis-catalog +/** + * Stream the panel state of a Session: the current state first, then each + * later state. Intermediate states may be skipped when the reader is slower + * than the updates. The stream ends when `signal` aborts or the Remote stops. + * @param session - Session to follow. + * @param signal - ends the stream. + * @returns panel states in publication order. + */ +@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable + +/** + * List the local branches that can start a worktree of the Session's current checkout. + * @param session - Session whose current directory is listed. + * @param signal - request cancellation. + * @returns the checked-out branch and at most `maxBranches` local branches, the checked-out one first. + * @throws when the current directory is not inside a checkout or Git fails. + */ +@Remote async branches(session: Session, signal: AbortSignal): Promise + +/** + * Bind a change request to a Session by URL, or restore a dismissed binding. + * @param session - Session that receives the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + * @throws `forgePanel/unrecognized-url` when no registered provider serves the URL. + */ +@Remote async track(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Hide a visible binding of a Session; other URLs leave the Session unchanged. + * @param session - Session that owns the binding. + * @param url - web URL of the change request. + * @param signal - request cancellation. + */ +@Remote async dismiss(session: Session, url: string, signal: AbortSignal): Promise + +/** + * Reinspect a followed Session's checkout, probe sign-in again, and poll its + * change requests at once; an unfollowed Session is unchanged. + * @param session - Session whose panel refreshes. + * @param signal - request cancellation. + */ +@Remote async refresh(session: Session, signal: AbortSignal): Promise + +/** + * Ask for a new worktree before the Session's first turn, replacing an open + * request and forgetting a failed attempt. Admission of the next message, or + * else the first step proposal, creates a branch from `from`, or from HEAD of + * the current checkout when it is null, and enters its checkout. + * @param session - Session without a started turn. + * @param from - local base branch, or null for HEAD. + * @param signal - request cancellation. + * @throws `forgePanel/invalid-branch` when `from` is empty; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-started` after a turn started or the worktree was + * created; `forgePanel/session-busy` while the worktree is being created. + */ +@Remote async requestWorktree(session: Session, from: string | null, signal: AbortSignal): Promise + +/** + * Withdraw the open worktree request of a Session with its failed attempt; without a request the Session is unchanged. + * @param session - Session without a started turn. + * @param signal - request cancellation. + * @throws `forgePanel/worktrees-unavailable` without a worktree service; `forgePanel/session-started` + * after a turn started or the worktree was created; `forgePanel/session-busy` while the worktree is + * being created. + */ +@Remote async cancelWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Stop the creation that holds a message of the Session at admission; the + * message goes back to its sender and the request stays open. Without such a + * creation the Session is unchanged. + * @param session - Session whose creation stops. + * @param signal - request cancellation. + */ +@Remote async stopCreation(session: Session, signal: AbortSignal): Promise + +/** + * Check out the created worktree of a Session again at its recorded + * directory when that directory is gone: its branch when the branch still + * exists, otherwise a new branch of the same name at the base commit. Changes + * that were never committed are not recovered. Runs as Agent maintenance; + * without a missing directory the Session is unchanged. + * @param session - Session whose worktree directory is gone. + * @param signal - request cancellation, which aborts the restoration. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/worktrees-unavailable` + * without a worktree service; `forgePanel/session-busy` while the Session's Agent is not loaded or + * has other work; `forgePanel/restore-failed` when Git fails. + */ +@Remote async restoreWorktree(session: Session, signal: AbortSignal): Promise + +/** + * Move a Session from its created worktree to the source checkout the + * worktree came from; the Session no longer works in the worktree. Runs as + * Agent maintenance. + * @param session - Session with a created worktree. + * @param signal - request cancellation. + * @throws `forgePanel/no-worktree` without a created worktree; `forgePanel/session-busy` while the + * Session's Agent is not loaded or has other work; the working-directory service's error when the + * source checkout no longer exists. + */ +@Remote async continueInProject(session: Session, signal: AbortSignal): Promise +``` + +Types: [Session](session.zh.md) + +Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts) + ### `ctx.forgeSession` — `ForgeSessionService` diff --git a/docs/subsystems/worktrees.i18n.yaml b/docs/subsystems/worktrees.i18n.yaml index 03544fbc81..f771dd3cf2 100644 --- a/docs/subsystems/worktrees.i18n.yaml +++ b/docs/subsystems/worktrees.i18n.yaml @@ -9,5 +9,5 @@ en: 53f643c5d30e00b2 zh: ba9e52efc62c7656 /git-worktrees/request-and-result: - en: 8391b091607600eb - zh: 6c735a337d60d566 + en: fbab866bf824d6e0 + zh: 73d2a1ee350e1434 diff --git a/docs/subsystems/worktrees.md b/docs/subsystems/worktrees.md index 14db6711bc..99f7a21391 100644 --- a/docs/subsystems/worktrees.md +++ b/docs/subsystems/worktrees.md @@ -14,7 +14,7 @@ Leaving through `working_directory({ cd: path })` retains the checkout and branc ## Request and result -The request selects only the new name and local revision. The result identifies the created checkout and exact commit; it does not change the Session's original project or write permissions. +The request selects only the new name and local revision. The result identifies the created checkout and exact commit; it does not change the Session's original project or write permissions. An optional callback receives each stage of the creation before it begins. A restoration checks a removed checkout out again at its recorded directory, from its branch or, when the branch is gone, from its base commit. ```ts type-equiv /** A new checkout from a locally available Git commit, branch, or tag. */ @@ -40,6 +40,39 @@ interface CreatedWorktree { } ``` +```ts type-equiv +/** + * Stage of one creation, reported before it begins: `prepare` validates the + * request, allocates the checkout directory, and creates the branch; + * `checkout` writes the files and enters the checkout. + */ +type CreateWorktreePhase = 'prepare' | 'checkout' +``` + +```ts type-equiv +/** A removed checkout to check out again at its recorded directory. */ +interface RestoreWorktreeRequest { + /** Canonical checkout directory the worktree had. */ + path: string + /** Branch the worktree had checked out. */ + branch: string + /** Commit the branch started from; a branch that no longer exists is created again here. */ + baseCommit: string + /** Canonical root of the source checkout. */ + repositoryRoot: string +} +``` + +```ts type-equiv +/** Outcome of one restoration. */ +interface RestoredWorktree { + /** Checkout directory, present again. */ + path: string + /** Whether the branch no longer existed and was created again at the base commit, without the commits it had gained. */ + branchRecreated: boolean +} +``` + @@ -65,9 +98,25 @@ Creates retained Git worktrees under the mounted filesystem and sandbox provider * @param agent - caller whose current directory selects the source repository and file policy. * @param request - optional new name and local revision; defaults are generated name and HEAD. * @param signal - cancellation of lookup, creation, and working-directory publication. + * @param onPhase - receives each stage before it begins; it must not throw. * @returns canonical checkout path, branch name, pinned commit, and source repository root. */ -async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise +async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise + +/** + * Check out a removed worktree again at its recorded directory: its branch + * when the branch still exists, otherwise a new branch of the same name at + * `baseCommit`. Stale worktree registrations are pruned first, and an + * existing directory fails. Changes that were never committed are not + * recovered. Checkout disables configured clean, smudge, and process filters, + * as creation does. The calling Session's working directory does not change. + * Failures may retain newly allocated Git/filesystem artifacts. + * @param agent - caller whose sandbox policy confines the commands. + * @param request - recorded directory, branch, base commit, and source repository root. + * @param signal - cancellation of the restoration. + * @returns the directory and whether the branch was created again. + */ +async restore(agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.md) diff --git a/docs/subsystems/worktrees.zh.md b/docs/subsystems/worktrees.zh.md index 719d99266b..764a4d804b 100644 --- a/docs/subsystems/worktrees.zh.md +++ b/docs/subsystems/worktrees.zh.md @@ -14,7 +14,7 @@ ## 请求与结果 -请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。 +请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。可选的回调会在创建的每个阶段开始之前收到该阶段。恢复会在记录的目录重新检出已删除的检出目录:从它的分支检出;分支也已删除时,从它的基准提交检出。 ```ts type-equiv /** A new checkout from a locally available Git commit, branch, or tag. */ @@ -40,6 +40,39 @@ interface CreatedWorktree { } ``` +```ts type-equiv +/** + * Stage of one creation, reported before it begins: `prepare` validates the + * request, allocates the checkout directory, and creates the branch; + * `checkout` writes the files and enters the checkout. + */ +type CreateWorktreePhase = 'prepare' | 'checkout' +``` + +```ts type-equiv +/** A removed checkout to check out again at its recorded directory. */ +interface RestoreWorktreeRequest { + /** Canonical checkout directory the worktree had. */ + path: string + /** Branch the worktree had checked out. */ + branch: string + /** Commit the branch started from; a branch that no longer exists is created again here. */ + baseCommit: string + /** Canonical root of the source checkout. */ + repositoryRoot: string +} +``` + +```ts type-equiv +/** Outcome of one restoration. */ +interface RestoredWorktree { + /** Checkout directory, present again. */ + path: string + /** Whether the branch no longer existed and was created again at the base commit, without the commits it had gained. */ + branchRecreated: boolean +} +``` + @@ -65,9 +98,25 @@ Creates retained Git worktrees under the mounted filesystem and sandbox provider * @param agent - caller whose current directory selects the source repository and file policy. * @param request - optional new name and local revision; defaults are generated name and HEAD. * @param signal - cancellation of lookup, creation, and working-directory publication. + * @param onPhase - receives each stage before it begins; it must not throw. * @returns canonical checkout path, branch name, pinned commit, and source repository root. */ -async create(agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal): Promise +async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise + +/** + * Check out a removed worktree again at its recorded directory: its branch + * when the branch still exists, otherwise a new branch of the same name at + * `baseCommit`. Stale worktree registrations are pruned first, and an + * existing directory fails. Changes that were never committed are not + * recovered. Checkout disables configured clean, smudge, and process filters, + * as creation does. The calling Session's working directory does not change. + * Failures may retain newly allocated Git/filesystem artifacts. + * @param agent - caller whose sandbox policy confines the commands. + * @param request - recorded directory, branch, base commit, and source repository root. + * @param signal - cancellation of the restoration. + * @returns the directory and whether the branch was created again. + */ +async restore(agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal): Promise ``` Types: [Agent](core.zh.md) diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index db23fbf232..14b24b40fb 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -234,6 +234,7 @@ export const OPTIONAL_BUNDLES: readonly string[] = [ '@deepseek-ai/dsh-experimental-inspector-profile', '@deepseek-ai/dsh-experimental-plugin-market-bundle', '@deepseek-ai/dsh-experimental-tool-worktree', + '@deepseek-ai/dsh-experimental-forge-bundle', ] const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: diff --git a/packages/experimental/README.i18n.yaml b/packages/experimental/README.i18n.yaml index 051d8efc49..41165b13ec 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/experimental/README.i18n.yaml @@ -18,11 +18,11 @@ en: b55aeb927737b90d zh: 0b215fcb5c2c7908 /packages-experimental/packages: - en: eea6339298b79e08 - zh: de048fe6a49c8a04 + en: e24b3cfea7fae889 + zh: 0a478989357dc799 /packages-experimental/related-documentation: - en: 3add3dbe9b625de7 - zh: cacba37c7834ff9d + en: 40797d4849455966 + zh: bb91a68dd995887c /packages-experimental/dev-note: en: 5de1d6304e00c58f zh: 8d8225361cfa29e5 diff --git a/packages/experimental/README.md b/packages/experimental/README.md index bef3e07f40..f3019e9c37 100644 --- a/packages/experimental/README.md +++ b/packages/experimental/README.md @@ -89,6 +89,8 @@ Promotion requires a named product role, documented public behavior and limitati | [`forge`](forge/README.md) | Code host provider registry with shared change request polling | `ctx.forge` | | [`forge-github`](forge-github/README.md) | GitHub provider that reads pull requests through the host GitHub CLI | registers a provider on `ctx.forge` | | [`forge-session`](forge-session/README.md) | Binds change requests to the Sessions that created, pushed, or tracked them | `ctx.forgeSession` | +| [`client-ui-forge`](client-ui-forge/README.md) | GUI panel for a Session's Git checkout, worktrees, and bound pull requests | `ctx.forgePanel` | +| [`forge-bundle`](forge-bundle/README.md) | Default-disabled pull request and worktree panel composition | — | ----- @@ -97,7 +99,7 @@ Promotion requires a named product role, documented public behavior and limitati - [Experimental publication reference](../../scripts/experimental-package-policy.ts) — public defaults and private exceptions. - [Worktrees](../../docs/subsystems/worktrees.md) — explicit branch and checkout creation. -- [Forges](../../docs/subsystems/forge.md) — change request providers, status polling, and Session bindings. +- [Forges](../../docs/subsystems/forge.md) — change request providers, status polling, Session bindings, and the panel. - [Computer use](../../docs/subsystems/computer-use.md) — desktop provider choices. - [Browser use](../../docs/subsystems/browser-use.md) — browser provider choices and Session ownership. - [Agent Teams subsystem](../../docs/subsystems/agent-team.md) — durable Team types and the `ctx.agentTeams` service API. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md index 119c7cbef1..b2214af182 100644 --- a/packages/experimental/README.zh.md +++ b/packages/experimental/README.zh.md @@ -89,6 +89,8 @@ kind: "package-group" | [`forge`](forge/README.zh.md) | 代码托管提供方注册表,共享变更请求轮询 | `ctx.forge` | | [`forge-github`](forge-github/README.zh.md) | 通过宿主 GitHub CLI 读取 pull request 的 GitHub 提供方 | 在 `ctx.forge` 上注册提供方 | | [`forge-session`](forge-session/README.zh.md) | 把变更请求绑定到创建、推送或跟踪它们的 Session | `ctx.forgeSession` | +| [`client-ui-forge`](client-ui-forge/README.zh.md) | 展示 Session 的 Git 检出、worktree 与已绑定 pull request 的 GUI 面板 | `ctx.forgePanel` | +| [`forge-bundle`](forge-bundle/README.zh.md) | 默认禁用的 pull request 与 worktree 面板组合 | — | ----- @@ -97,7 +99,7 @@ kind: "package-group" - [实验包发布说明](../../scripts/experimental-package-policy.ts)——默认公开与私有例外。 - [工作树](../../docs/subsystems/worktrees.zh.md)——显式创建分支与检出目录。 -- [Forges](../../docs/subsystems/forge.zh.md)——变更请求提供方、状态轮询与 Session 绑定。 +- [Forges](../../docs/subsystems/forge.zh.md)——变更请求提供方、状态轮询、Session 绑定与面板。 - [计算机操作](../../docs/subsystems/computer-use.zh.md)——桌面提供方选择。 - [浏览器操作](../../docs/subsystems/browser-use.zh.md)——浏览器提供方选择与 Session 所有权。 - [Agent Teams 子系统](../../docs/subsystems/agent-team.zh.md)——持久 Team 类型与 `ctx.agentTeams` 服务 API。 diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml new file mode 100644 index 0000000000..612b9b0805 --- /dev/null +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -0,0 +1,37 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/experimental/client-ui-forge/README.md +/: + en: 6c9e355ea30ce2ce + zh: 19a587e050e65126 +/deepseek-ai-dsh-experimental-client-ui-forge: + en: 8ca3d802a44c6fd1 + zh: 2723c9e28ecea7f7 +/deepseek-ai-dsh-experimental-client-ui-forge/summary: + en: d164a212fc32f3da + zh: 39d7fbfb73503aa9 +/deepseek-ai-dsh-experimental-client-ui-forge/table-of-contents: + en: d152484eb41ac6b4 + zh: 2f8a621b991260a1 +/deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: + en: 491e1b3af2a95c7c + zh: 110743d500fd25d7 +/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: + en: 0b14b455d5cb74b1 + zh: 2832d37da95cd07d +/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: + en: 34db980ac035b73d + zh: 246cf0eb3e304f7f +/deepseek-ai-dsh-experimental-client-ui-forge/model-experience: + en: 30a7ffc18db56b62 + zh: ef8203c78c0b2c31 +/deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect: + en: dde484510338d9e9 + zh: 1107e4f78343e322 +/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: + en: d21772433c21d21c + zh: 7b242a37fe09d9d8 +/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: + en: e67f633dc6fe7773 + zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md new file mode 100644 index 0000000000..639f5d658c --- /dev/null +++ b/packages/experimental/client-ui-forge/README.md @@ -0,0 +1,112 @@ +--- +description: "GUI panel for the pull requests a Session creates, and a worktree option for new Sessions, with the Host Remote that serves them." +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-client-ui-forge + +English | [中文](README.zh.md) + +## Summary + +This optional two-face package shows the pull requests a Session created or pushed, and lets a new Session start in its own Git worktree. The Host half is the `forgePanel` Remote: it inspects the Session's checkout read-only, recognizes the code host, checks sign-in, streams the Session's [bound change requests](../forge-session/README.md) with their polled status, and creates a requested worktree while the Session's first message waits at Agent admission. The Client half adds a right-sidebar tab, a Session header button and branch label, a New Session option, a creation card above the composer, and a toast for action outcomes. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it composes this package with the forge registry, the GitHub provider, the Session bindings, and its own [worktree service](../worktree/README.md). + +- **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message keeps it in the composer on the New Session screen, shown as sending, while the branch `dsh/<8 hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the Host accepts the message once the worktree exists. +- **Creation card** — while the worktree is created, a card above the composer shows its branch and the preparation and checkout steps; More details adds the branch and base. Cancel stops the creation and returns the message to editing with the option kept. A failed attempt returns the message to editing, and the card shows the reason: sending the message again tries again with a new branch name, and Continue without a worktree withdraws the request so the next send uses the current checkout. +- **Branch label** — the Session header shows the branch the Session works on; its tooltip names the worktree and its base, or the current checkout. +- **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and the Host returns each message to the composer until the worktree is restored or left. Restore worktree checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; Continue in the project moves the Session to the source checkout. The branch label shows the warning tone meanwhile. +- **Header button** — appears when the Session has bound pull requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open pull request with failed checks, requested changes, or conflicts. +- **Tab** — a row names the worktree the Session started in and its base, or the checked-out branch. Below it, each pull request card shows the state glyph, title, number, branch, changed lines, checks, and the most urgent of requested changes, conflicts, or draft; a merged or closed one shows when that happened. Expanding a card adds View pull request and Copy link, the state and how it was bound, the check list with durations, the review decision with unresolved threads, mergeability, Add to chat, which inserts the URL into the composer, and Remove from Session, whose toast offers Undo. Refresh inspects the checkout, checks sign-in, and polls every pull request again. A banner shows the `gh auth login` command when sign-in is missing. + +`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, and `worktreePrefix` are listed in the [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-client-ui-forge). + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +`ForgePanelController` keeps one `SessionPanel` per followed Session and disposes it when the last follower leaves. A panel starts with the Session's working directory and bindings, re-inspects after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and publishes a new `ForgePanelState` for every change. Each change request keeps the latest snapshot a poll returned while later polls fail. Inspections run one at a time with one trailing rerun, and a result for a directory the Session has since left is dropped. The code host comes from the remotes in order: the upstream's remote, then `origin`, then the rest. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`. + +Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `symbolic-ref --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=`. None reads the working tree. Every command runs under the Session's read-only sandbox policy with optional index writes, hooks, the filesystem monitor, prompts, and lazy object fetches disabled and the inherited Git selectors and secret-bearing variables removed, within its own `gitTimeoutMs` deadline and `maxOutputBytes` limit per stream. The top-level and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records. The worktree option is usable under the `danger-full-access` sandbox mode, never under `read-only`, and under `workspace-write` only when both directories lie inside the canonical workspace root of the Session's sandbox policy. + +A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the host-only `forgeWorktree` projection: requests and withdrawals apply until the first `turn/start` or creation, and the first `created` record of an open request applies until the first `step/start`. `requestWorktree` and `cancelWorktree` refuse with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; both forget a failed attempt. An `agent-controller/admission` listener holds a message of a Session whose request is open while one attempt with a new branch name runs as maintenance of the Session's Agent, so a message from another client waits for it; it publishes the running stage in the panel state with `held` set, records `created` with the source repository root, and then lets the message enter. A failed attempt stays in the panel state as `failure`, with its branch, base, failed step, and reason, until another attempt starts or the request changes, and the message is refused with `forge/worktree-creation-failed`. `stopCreation` and the Agent's cancellation refuse it with `forge/worktree-creation-stopped`, and the sender's cancellation passes through. Each message of a Session that works in its created worktree is refused with `forge/worktree-missing` while the worktree is missing; a check that cannot answer lets it pass. A refusal is marked `presented` while a panel follows the Session. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message that entered without admission; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` also run as maintenance. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`. + +Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. + +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the branch label, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state for the next subscriber. The New Session option fills `conversation.hero.options`, the worktree card `conversation.input.dock`, and the branch label `conversation.session.header.actions`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the last failure, with Continue without a worktree calling `cancelWorktree`; the composer itself shows the waiting message as sending and keeps a refused one. The tab reports its visibility to the header button's pressed state. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. + +| Source | Responsibility | +|---|---| +| [`src/index.ts`](src/index.ts) | `forgePanel` Remote and per-Session panels | +| [`src/panel.ts`](src/panel.ts) | Panel state, inspection scheduling, and sign-in probes | +| [`src/worktree.ts`](src/worktree.ts) | Worktree record, projection, and creation at admission and at the first step | +| [`src/git.ts`](src/git.ts) | Confined read-only Git commands | +| [`src/parse.ts`](src/parse.ts) | Git output parsing and remote order | +| [`src/client/mount.ts`](src/client/mount.ts) | Remote mount, slot entries, and action outcome mapping | +| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | Reusable change request card | +| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | New Session worktree option | +| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | Worktree creation and removed-worktree card | +| [`src/client/BranchChip.tsx`](src/client/BranchChip.tsx) | Session header branch label | + +
+ +----- + + +## Further Exploration + +- [Forge subsystem](../../../docs/subsystems/forge.md) — change request types and service reference. +- [Session bindings](../forge-session/README.md) — how pull requests bind to a Session. +- [Worktree service](../worktree/README.md) — how a worktree is created and entered. + +----- + + +## Model Experience + +Indirectly, through the working-directory service: its notice about a Session's new worktree joins the first request's messages, and Continue in the project queues the same kind of notice for the next request. Inspection, status, restoring, and the panel add nothing to model requests. + +#### KV Cache effect + +None for a new worktree, as its notice belongs to the first request of the Session, before any cached prefix exists. A notice after Continue in the project is appended after the cached prefix. + +## Known Limitations and Deferred Work + + + +- **Panel-only status** — status is polled only while a surface of this package is mounted for the Session; there are no notifications for checks that finish while none is. +- **Inspection triggers** — the checkout is inspected after Session tool results and directory changes; a change made outside the Session appears after Refresh or the next tool result. +- **Creation only** — the panel creates a worktree only before a Session's first step and never removes or prunes one; a failed or cancelled attempt can leave its branch or checkout behind. Branch names are random, not derived from the Session title, which does not exist yet. +- **Senders without admission** — only the Web Session Remote delivers messages through the agent controller's `agent-controller/admission` stage. A first message from another sender starts the turn, and the worktree is created at its first step; a message to a removed worktree enters, and its tools fail on the missing directory. +- **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. +- **Linked-worktree projects** — the option is unavailable when the project is itself a linked worktree: under workspace-write its Git directory lies outside the writable root, so neither the new branch nor later commits could be written. +- **Review page** — deferred: a left-navigation page that lists the user's pull requests from the selected provider; the [decision note](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.md) records where it attaches. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md new file mode 100644 index 0000000000..0c1aa41099 --- /dev/null +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -0,0 +1,112 @@ +--- +description: "展示 Session 创建的 pull request、并让新 Session 以 worktree 开始的 GUI 面板,以及为它们提供数据的宿主 Remote。" +kind: "package-reference" +--- + +# @deepseek-ai/dsh-experimental-client-ui-forge + +[English](README.md) | 中文 + +## 概述 + +这个可选的双端包展示 Session 创建或推送的 pull request,并让新 Session 在自己的 Git worktree 中开始。宿主端是 `forgePanel` Remote:它以只读方式检查 Session 的检出、识别代码托管平台、检查登录状态,以流的形式推送 Session 的[已绑定变更请求](../forge-session/README.zh.md)及其轮询状态,并在 Session 的第一条消息于 Agent 接纳阶段等待时创建所请求的 worktree。客户端增加一个右侧边栏标签页、Session 标题栏中的一个按钮与分支标签、一个新会话选项、输入框上方的创建卡片,以及报告操作结果的 toast 提示。 + +## 目录 + +- [使用本包](#use-this-package) +- [了解实现](#understand-the-implementation) +- [深入探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与待办工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在插件页启用 [forge 套件](../forge-bundle/README.zh.md);它把本包与 forge 注册表、GitHub 提供方、Session 绑定以及它自己的 [worktree 服务](../worktree/README.zh.md)组合在一起。 + +- **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息以“正在发送”的状态留在新会话页的输入框里,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh/<8 位十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后宿主才接收这条消息。 +- **创建卡片**——创建 worktree 期间,输入框上方的卡片显示它的分支以及“准备工作空间”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止创建,让消息回到可编辑状态,勾选保留。某次尝试失败时,消息回到可编辑状态,卡片显示原因:再次发送消息即换一个新分支名重试;“不使用工作树”撤回请求,下一次发送就在当前检出中进行。 +- **分支标签**——Session 标题栏显示 Session 所在的分支;它的提示说明所在的 worktree 及其基准,或当前检出。 +- **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;在恢复或离开该 worktree 之前,宿主会把每条消息退回输入框。“恢复工作树”在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支。“在项目目录继续”把 Session 移回源检出。此期间分支标签显示警示色。 +- **标题栏按钮**——Session 有已绑定的 pull request、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的 pull request 存在检查失败、被要求修改或冲突时,按钮带红点。 +- **标签页**——顶部一行说明 Session 开始时所在的 worktree 及其基准,或当前检出的分支。其下每张 pull request 卡片显示状态图标、标题、编号、分支、改动行数、检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的显示发生时间。展开卡片会增加“查看 PR”与复制链接、状态与绑定方式、带耗时的检查列表、评审结论与未解决讨论数、能否合并、“添加到聊天”(把 URL 插入输入框),以及“从会话中移除 PR”,其 toast 提供“撤销”。刷新会重新检查检出与登录状态,并立即再次轮询每个 pull request。缺少登录时,横幅显示 `gh auth login` 命令。 + +`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches` 与 `worktreePrefix` 列在[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-client-ui-forge)中。 + +----- + + +## 了解实现 + +
+实现细节——点击展开 + +`ForgePanelController` 为每个被关注的 Session 保留一个 `SessionPanel`,最后一个关注方离开时销毁它。面板从 Session 的工作目录与绑定开始,在每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后重新检查,每次变化都发布新的 `ForgePanelState`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态。 + +检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`symbolic-ref --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=`。这些命令都不读取工作树。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。 + +worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 与 `cancelWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;两者都会忘掉失败的尝试。一个 `agent-controller/admission` 监听器在 Session 有未完成请求时暂留消息,同时以 Session 所属 Agent 的维护任务运行一次尝试,使用新分支名,因此其他客户端此时发来的消息会等它完成;它在面板状态中发布正在进行的阶段并设置 `held`,记录带源仓库根目录的 `created`,然后放行消息。失败的尝试以 `failure` 留在面板状态中,带有分支、基准、失败步骤与原因,直到下一次尝试开始或请求变更,消息则以 `forge/worktree-creation-failed` 被拒绝。`stopCreation` 与 Agent 的取消以 `forge/worktree-creation-stopped` 拒绝消息,发送方的取消原样传出。在所创建 worktree 中工作的 Session,其每条消息在 worktree 缺失时以 `forge/worktree-missing` 被拒绝;检查无法给出结果时放行。有面板跟随该 Session 时,拒绝标记为 `presented`。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖未经接纳就进入的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。`restoreWorktree` 与 `continueInProject` 同样以维护任务运行。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。 + +每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 + +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、分支标签、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,并为下一个订阅方保留最后的状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`,分支标签填入 `conversation.session.header.actions`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示最近一次失败,“不使用工作树”调用 `cancelWorktree`。等待中的消息由输入框自己显示为正在发送,被拒绝的消息留在输入框里。标签页把自己是否可见报告给标题栏按钮的按下状态。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。 + +| 源码 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `forgePanel` Remote 与按 Session 的面板 | +| [`src/panel.ts`](src/panel.ts) | 面板状态、检查调度与登录检查 | +| [`src/worktree.ts`](src/worktree.ts) | worktree 记录、投影,以及在接纳阶段与第一步创建 worktree | +| [`src/git.ts`](src/git.ts) | 受限的只读 Git 命令 | +| [`src/parse.ts`](src/parse.ts) | Git 输出解析与远端顺序 | +| [`src/client/mount.ts`](src/client/mount.ts) | Remote 挂载、slot 条目与操作结果映射 | +| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | 可复用的变更请求卡片 | +| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 | +| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 | +| [`src/client/BranchChip.tsx`](src/client/BranchChip.tsx) | Session 标题栏的分支标签 | + +
+ +----- + + +## 深入探索 + +- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——变更请求类型与服务参考。 +- [Session 绑定](../forge-session/README.zh.md)——pull request 如何绑定到 Session。 +- [Worktree 服务](../worktree/README.zh.md)——worktree 如何创建并进入。 + +----- + + +## 模型体验 + +间接影响,经由工作目录服务:它关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知。检查、状态、恢复与面板都不会给模型请求增加内容。 + +#### KV Cache 影响 + +新 worktree 的通知没有影响,因为它属于 Session 的第一次请求,那时还没有任何缓存前缀。“在项目目录继续”之后的通知追加在缓存前缀之后。 + +## 已知限制与待办工作 + + + +- **状态只在面板中轮询**——只有本包为该 Session 挂载了某个界面时才会轮询状态;都未挂载时检查结束不会有通知。 +- **检查触发时机**——检出只在 Session 的工具结果与目录变化之后检查;Session 之外的改动要等到刷新或下一个工具结果才会出现。 +- **只负责创建**——面板只在 Session 的第一步之前创建 worktree,从不删除或清理;失败或被取消的尝试可能留下分支或检出。分支名是随机的,不取自 Session 标题,因为那时标题还不存在。 +- **不经接纳的发送方**——只有 Web Session Remote 经 agent controller 的 `agent-controller/admission` 阶段投递消息。其他发送方发出的第一条消息会直接开始这一轮,worktree 在第一步创建;发往已删除 worktree 的消息照常进入,其中的工具会因目录不存在而失败。 +- **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。 +- **链接 worktree 项目**——项目本身是链接 worktree 时该选项不可用:在 workspace-write 下它的 Git 目录位于可写根之外,新分支与之后的提交都无法写入。 +- **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的 pull request;[决策笔记](../../../.agents/notes/implemented/feature/2026-10-09-forge-panel.zh.md)记录了它的接入位置。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/experimental/client-ui-forge/locale/en.json b/packages/experimental/client-ui-forge/locale/en.json new file mode 100644 index 0000000000..5eea7dfe8e --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/en.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "Pull request panel", + "description": "Shows a Session's pull requests in a sidebar tab and lets a new Session start in a worktree." + } +} diff --git a/packages/experimental/client-ui-forge/locale/zh.json b/packages/experimental/client-ui-forge/locale/zh.json new file mode 100644 index 0000000000..d496c0f34e --- /dev/null +++ b/packages/experimental/client-ui-forge/locale/zh.json @@ -0,0 +1,6 @@ +{ + "meta": { + "title": "PR 面板", + "description": "在侧栏标签页中显示会话的 Pull Request,并让新会话可以在工作树中开始。" + } +} diff --git a/packages/experimental/client-ui-forge/package.json b/packages/experimental/client-ui-forge/package.json new file mode 100644 index 0000000000..b61008267f --- /dev/null +++ b/packages/experimental/client-ui-forge/package.json @@ -0,0 +1,129 @@ +{ + "name": "@deepseek-ai/dsh-experimental-client-ui-forge", + "description": "Sidebar panel for a Session's pull requests, with a worktree option for new Sessions and its Host Remote", + "version": "0.2.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/experimental/client-ui-forge" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./locale/*.json": "./locale/*.json", + "./types": { + "types": "./lib/types/types.d.ts" + } + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-api-remotes", + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-layout", + "@deepseek-ai/dsh-client-ui-sidebar-right", + "@deepseek-ai/dsh-client-ui-workspace" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "MIT", + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-controller": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", + "@deepseek-ai/dsh-experimental-worktree": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:~", + "@deepseek-ai/dsh-timeout": "workspace:*", + "@deepseek-ai/dsh-util-values": "workspace:*", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-controller": "workspace:*", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", + "@deepseek-ai/dsh-api-remotes": "workspace:*", + "@deepseek-ai/dsh-api-session-controller": "workspace:*", + "@deepseek-ai/dsh-client-locale": "workspace:*", + "@deepseek-ai/dsh-client-store": "workspace:*", + "@deepseek-ai/dsh-client-test-runtime": "workspace:*", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:*", + "@deepseek-ai/dsh-client-ui-layout": "workspace:*", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:*", + "@deepseek-ai/dsh-client-ui-renderer": "workspace:*", + "@deepseek-ai/dsh-client-ui-session": "workspace:*", + "@deepseek-ai/dsh-client-ui-sidebar-right": "workspace:*", + "@deepseek-ai/dsh-client-ui-slots": "workspace:*", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:*", + "@deepseek-ai/dsh-experimental-forge": "workspace:*", + "@deepseek-ai/dsh-experimental-forge-session": "workspace:*", + "@deepseek-ai/dsh-experimental-worktree": "workspace:*", + "@deepseek-ai/dsh-fs": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", + "@deepseek-ai/dsh-llm": "workspace:*", + "@deepseek-ai/dsh-sandbox": "workspace:*", + "@deepseek-ai/dsh-sandbox-local": "workspace:*", + "@deepseek-ai/dsh-sandbox-policy": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", + "@deepseek-ai/dsh-session-projection": "workspace:*", + "@deepseek-ai/dsh-subprocess": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", + "@deepseek-ai/dsh-typert-protocol": "workspace:*", + "@deepseek-ai/dsh-working-directory": "workspace:*", + "@testing-library/react": "^16.3.3", + "clsx": "^2.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "files": [ + "locale/*.json", + "lib/index.js", + "lib/client.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" + ] +} diff --git a/packages/experimental/client-ui-forge/src/client/ActionToast.tsx b/packages/experimental/client-ui-forge/src/client/ActionToast.tsx new file mode 100644 index 0000000000..c2430c2f8d --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ActionToast.tsx @@ -0,0 +1,38 @@ +/** App-wide notice for forge panel actions, rendered outside the panel so it outlives it. */ + +import type { ReactNode } from 'react' +import { IconWarningOutlineRegular, Toast } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import type { NoticeSource } from './source.ts' + +/** Full-opacity hold of a notice that offers to undo its action, long enough to reach the action. */ +const UNDO_HOLD_MS = 6000 + +/** Full props of the `shell.overlay` entry. */ +export type ActionToastProps = PropsLocale & InjectFace> + +/** + * Render the current notice: a success notice with its undo action, or a failure warning. + * @param props - notice hook, dismissal, and copy. + * @returns the toast, or nothing while no notice shows. + */ +export function ActionToast({ useNotice, dismiss, t }: ActionToastProps): ReactNode { + const notice = useNotice(current => current) + if (notice === null) return null + const { undo } = notice + return ( + }} + {...undo === undefined + ? {} + : { + holdMs: UNDO_HOLD_MS, + actions: [{ label: t('toast.undo'), onClick: () => { dismiss(); undo() } }], + }} + onDone={dismiss} + /> + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/BranchChip.module.css b/packages/experimental/client-ui-forge/src/client/BranchChip.module.css new file mode 100644 index 0000000000..d5a19a295d --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/BranchChip.module.css @@ -0,0 +1,39 @@ +/* Branch label beside the Session title: a quiet filled chip in code type. */ +.chip { + --forge-fill: var(--dsw-static-neutral-50); + display: inline-flex; + flex: 0 1 auto; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 240px; + height: 22px; + box-sizing: border-box; + padding: 0 8px; + border-radius: 6px; + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +:global(body[data-ds-dark-theme]) .chip { + --forge-fill: var(--dsw-static-neutral-850); +} + +/* The worktree the Session works in is gone. */ +.chip[data-missing] { + color: var(--dsw-alias-state-warn-primary); +} + +.glyph { + flex: none; +} + +.text { + min-width: 0; + overflow: hidden; + font-family: var(--ds-font-family-code); + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/experimental/client-ui-forge/src/client/BranchChip.tsx b/packages/experimental/client-ui-forge/src/client/BranchChip.tsx new file mode 100644 index 0000000000..bfbe31958b --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/BranchChip.tsx @@ -0,0 +1,62 @@ +/** Session header label of the branch the Session works on, and of the worktree it started in. */ + +import type { ReactNode } from 'react' +import { IconBranchOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ForgePanelState } from '../types.ts' +import type { NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './BranchChip.module.css' + +/** Business face of the header label. */ +export interface BranchChipInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } +} + +/** Full props of the header label. */ +export type BranchChipProps = InjectFace & PropsLocale + +type Translate = BranchChipProps['t'] + +/** What the label shows: the branch, its explanation, and whether its worktree is gone. */ +interface BranchLabel { + readonly branch: string + readonly detail: string + readonly missing: boolean +} + +/** The label, or undefined outside a checkout with a known branch. */ +function labelOf(state: ForgePanelState | null, t: Translate): BranchLabel | undefined { + const created = state?.worktree.created ?? null + if (state !== null && state.worktree.missing !== null && created !== null) { + return { branch: created.branch, detail: t('branch.missing', { path: created.path }), missing: true } + } + if (state?.checkout.state !== 'ready') return undefined + const { root, branch } = state.checkout.checkout + if (created !== null && created.path === root) { + const detail = t('branch.worktree', { path: created.path, base: created.from ?? created.baseCommit.slice(0, 7) }) + return { branch: created.branch, detail, missing: false } + } + return branch === null ? undefined : { branch, detail: t('branch.current', { root }), missing: false } +} + +/** + * Render the branch label beside the Session title, in the warning tone while its worktree is gone. + * @param props - panel snapshot hook and copy. + * @returns the label, or nothing outside a checkout with a known branch. + */ +export function BranchChip({ usePanel, t }: BranchChipProps): ReactNode { + const state = usePanel(snapshot => snapshot.state) + const label = labelOf(state, t) + if (label === undefined) return null + return ( + + + + {label.branch} + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx new file mode 100644 index 0000000000..f60edaf25c --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -0,0 +1,328 @@ +/** One change request as a collapsible card: a status summary, and its details and actions when expanded. */ + +import { useState, type ReactNode } from 'react' +import clsx from 'clsx' +import { + Button, IconBranchOutlineRegular, IconCheckOutlineRegular, IconChecklistOutlineRegular, IconChevronDownOutlineRegular, + IconCloseOutlineRegular, IconContextInjectionOutlineRegular, IconCopyOutlineRegular, IconInfoOutlineRegular, + IconRightUpOutlineRegular, IconTrashOutlineRegular, IconUsersOutlineRegular, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ChangeRequestCheck, ChangeRequestSnapshot, ForgeFailure } from '@deepseek-ai/dsh-experimental-forge/types' +import type { BindingOrigin } from '@deepseek-ai/dsh-experimental-forge-session/types' +import type { ForgePanelChangeRequest } from '../types.ts' +import { formatAgo, formatDuration } from './format.ts' +import type { ForgePanelKey, NS } from './locales.ts' +import { checkCounts, checkDuration, ciSummary, flagOf, numberOf, toneOf } from './status.ts' +import css from './ForgeTab.module.css' + +type Translate = PropsLocale['t'] + +/** What a card can do; a surface without a composer or Session binding omits the optional actions. */ +export interface ChangeRequestActions { + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy a URL to the clipboard. */ + readonly copyLink: (url: string) => void + /** Read the status again after a failed read. */ + readonly retry: () => void + /** Insert the change request URL into the composer. */ + readonly addToChat?: ((url: string) => void) | undefined + /** Remove the change request from its Session. */ + readonly remove?: ((entry: ForgePanelChangeRequest) => void) | undefined +} + +/** Props of one change request card. */ +export interface ChangeRequestCardProps { + /** Change request with its latest snapshot and polling result. */ + readonly entry: ForgePanelChangeRequest + /** Whether the details show. */ + readonly expanded: boolean + /** Show or hide the details. */ + readonly onToggle: () => void + /** Current epoch milliseconds for relative times. */ + readonly now: number + /** Card actions. */ + readonly actions: ChangeRequestActions + /** Forge panel copy. */ + readonly t: Translate +} + +const ORIGIN: Record = { + created: 'change.origin.created', + discovered: 'change.origin.discovered', + tracked: 'change.origin.tracked', +} + +const REVIEW: Record = { + 'approved': 'change.review.approved', + 'changes-requested': 'change.review.changesRequested', + 'review-required': 'change.review.required', + 'none': 'change.review.none', +} + +const MERGE: Record = { + mergeable: 'change.merge.mergeable', + conflicting: 'change.merge.conflicting', + unknown: 'change.merge.unknown', +} + +const FAILURE: Record = { + 'not-found': 'change.failure.notFound', + 'unauthenticated': 'change.failure.unauthenticated', + 'unavailable': 'change.failure.unavailable', +} + +function stateKey(snapshot: ChangeRequestSnapshot): ForgePanelKey { + if (snapshot.state === 'open') return snapshot.draft ? 'change.state.draft' : 'change.state.open' + return snapshot.state === 'merged' ? 'change.state.merged' : 'change.state.closed' +} + +function failureOf(entry: ForgePanelChangeRequest): ForgeFailure | undefined { + return entry.update !== null && 'failure' in entry.update ? entry.update.failure : undefined +} + +function Diff({ snapshot }: { readonly snapshot: ChangeRequestSnapshot }): ReactNode { + return ( + + {`+${String(snapshot.additions)}`} + {`−${String(snapshot.deletions)}`} + + ) +} + +function Ci({ snapshot, t }: { readonly snapshot: ChangeRequestSnapshot; readonly t: Translate }): ReactNode { + const ci = ciSummary(snapshot) + switch (ci.kind) { + case 'none': return {t('change.ci.none')} + case 'running': return {t('change.ci.running')} + case 'passed': return {t('change.ci.passed')} + case 'failed': return ( + + + {ci.count > 0 ? t('change.ci.failed', { count: ci.count }) : t('change.ci.failedUnlisted')} + + ) + /* v8 ignore next -- closed CiSummary kind union exhaustiveness guard */ + default: return assertNever(ci, 'CiSummary.kind') + } +} + +/** Status line inside the toggle: diff size, then checks and the open-state flag, or the merge or close time. */ +function Summary({ entry, now, t }: { readonly entry: ForgePanelChangeRequest; readonly now: number; readonly t: Translate }): ReactNode { + const snapshot = entry.snapshot + if (entry.ref === null || failureOf(entry) !== undefined) return null + if (snapshot === null) { + return {t('change.loading')} + } + if (snapshot.state !== 'open') { + const ago = snapshot.closedAt === null ? undefined : formatAgo(snapshot.closedAt, now, t) + const state = t(stateKey(snapshot)) + return ( + + + {ago === undefined ? state : t('time.finished', { state, time: ago })} + + ) + } + const flag = flagOf(snapshot) + return ( + + + + {flag !== undefined && {t(`change.flag.${flag}`)}} + + ) +} + +function CheckIcon({ check }: { readonly check: ChangeRequestCheck }): ReactNode { + switch (check.state) { + case 'success': return + case 'failure': return + case 'pending': return + case 'neutral': + case 'skipped': + case 'cancelled': return + /* v8 ignore next -- closed CheckState union exhaustiveness guard */ + default: return assertNever(check.state, 'ChangeRequestCheck.state') + } +} + +const CHECK_RANK: Record = { + failure: 0, pending: 1, success: 2, neutral: 3, skipped: 3, cancelled: 3, +} + +/** Failed checks first, then running ones, then the rest, each group in host order. */ +function orderedChecks(checks: readonly ChangeRequestCheck[]): ChangeRequestCheck[] { + return checks.toSorted((left, right) => CHECK_RANK[left.state] - CHECK_RANK[right.state]) +} + +function checkTime(check: ChangeRequestCheck, t: Translate): string { + if (check.state === 'pending') return t(check.startedAt === null ? 'change.check.queued' : 'change.check.running') + const elapsed = checkDuration(check) + return elapsed === undefined ? '' : formatDuration(elapsed, t) +} + +function Checks({ snapshot, openLink, t }: { + readonly snapshot: ChangeRequestSnapshot + readonly openLink: (url: string) => void + readonly t: Translate +}): ReactNode { + const [open, setOpen] = useState(false) + const counts = checkCounts(snapshot.checks) + if (snapshot.checks.length === 0) { + return } label={t('change.row.checks')} value={t('change.ci.none')} /> + } + const summary = [ + ...counts.failed > 0 ? [t('change.checks.failed', { count: counts.failed })] : [], + ...counts.running > 0 ? [t('change.checks.running', { count: counts.running })] : [], + ...counts.passed > 0 ? [t('change.checks.passed', { count: counts.passed })] : [], + ...counts.other > 0 ? [t('change.checks.other', { count: counts.other })] : [], + ].join(' · ') + return ( + <> + + {open && ( +
    + {orderedChecks(snapshot.checks).map((check, index) => { + const body = ( + <> + + {check.name} + {checkTime(check, t)} + + ) + const url = check.url + return ( +
  • + {url === null + ?
    {body}
    + : } +
  • + ) + })} + {snapshot.checksTruncated &&
  • {t('change.checks.truncated', { count: snapshot.checks.length })}
  • } +
+ )} + + ) +} + +function Row({ icon, label, value, tone }: { + readonly icon: ReactNode + readonly label: string + readonly value: string + readonly tone?: 'error' | undefined +}): ReactNode { + return ( +
+ {icon} + {label} + {value} +
+ ) +} + +function Details({ entry, url, actions, t }: { + readonly entry: ForgePanelChangeRequest + readonly url: string + readonly actions: ChangeRequestActions + readonly t: Translate +}): ReactNode { + const snapshot = entry.snapshot + const { addToChat, remove } = actions + const review = snapshot === null ? '' : t(REVIEW[snapshot.reviewDecision]) + return ( +
+
+ + + + +
+ {snapshot !== null && ( + <> + } label={t('change.row.state')} value={`${t(stateKey(snapshot))} · ${t(ORIGIN[entry.origin])}`} /> + + } + label={t('change.row.review')} + value={snapshot.unresolvedThreads > 0 ? t('change.review.threads', { review, count: snapshot.unresolvedThreads }) : review} + tone={snapshot.reviewDecision === 'changes-requested' ? 'error' : undefined} + /> + } + label={t('change.row.merge')} + value={snapshot.state === 'open' ? t(MERGE[snapshot.mergeability]) : t(stateKey(snapshot))} + tone={snapshot.state === 'open' && snapshot.mergeability === 'conflicting' ? 'error' : undefined} + /> + + )} + {(addToChat !== undefined || remove !== undefined) &&
} + {addToChat !== undefined && ( + + )} + {remove !== undefined && ( + + )} +
+ ) +} + +/** + * Render one change request. + * @param props - change request, expansion, current time, actions, and copy. + * @returns the card. + */ +export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, t }: ChangeRequestCardProps): ReactNode { + const snapshot = entry.snapshot + const number = numberOf(entry) + const url = snapshot?.url ?? entry.url + const failure = failureOf(entry) + const notice = entry.ref === null ? 'change.unserved' : failure === undefined ? undefined : FAILURE[failure.code] + return ( +
  • + + {notice !== undefined && ( +
    + {t(notice)} + {entry.ref === null + ? + : } +
    + )} + {expanded &&
    } +
  • + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css new file mode 100644 index 0000000000..b87a4d71ae --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.module.css @@ -0,0 +1,490 @@ +/* Forge tab body: one scrolling column with the checkout row on top and the + Session's change request cards below, at the sidebar's 13px row size. */ +.root { + --forge-fill: var(--dsw-static-neutral-50); + display: flex; + flex-direction: column; + gap: 10px; + height: 100%; + min-height: 0; + box-sizing: border-box; + padding: 12px; + overflow-y: auto; + color: var(--dsw-alias-label-primary); + font-size: var(--dsh-content-font-size-secondary, 13px); + line-height: 20px; +} + +:global(body[data-ds-dark-theme]) .root { + --forge-fill: var(--dsw-static-neutral-850); +} + +/* Loading and disconnected states of a panel without state yet. */ +.centered { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + height: 100%; + box-sizing: border-box; + padding: 24px; + color: var(--dsw-alias-label-secondary); + font-size: var(--dsh-content-font-size-secondary, 13px); + text-align: center; +} + +.disconnected { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: var(--dsw-alias-label-secondary); +} + +.checkout { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding: 8px 10px; + border-radius: var(--dsw-radius-sm); + background: var(--forge-fill); + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.checkout:focus-visible { + outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); + outline-offset: 2px; +} + +.checkoutIcon { + flex: none; +} + +.checkoutBranch { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-primary); + font-family: var(--ds-font-family-code); + text-overflow: ellipsis; + white-space: nowrap; +} + +.checkoutNote { + flex: none; + margin-left: auto; +} + +.section { + display: flex; + flex-direction: column; + gap: 8px; + min-width: 0; +} + +.sectionHeader { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 4px 2px 0; +} + +.sectionTitle { + margin: 0; + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + font-size: 13px; + font-weight: 500; + line-height: 20px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.iconButton { + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.iconButton:hover, +.iconButton:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); +} + +.banner { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + padding: 10px 12px; + border-radius: var(--dsw-radius-sm); + background: var(--forge-fill); + color: var(--dsw-alias-label-primary); +} + +.bannerDetail { + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.bannerCommand { + max-width: 100%; + color: var(--dsw-alias-label-secondary); + font-family: var(--ds-font-family-code); + font-size: 12px; + line-height: 18px; + overflow-wrap: anywhere; +} + +.bannerAction { + flex: none; +} + +.empty { + padding: 32px 16px; + text-align: center; +} + +.emptyTitle { + margin: 0; + color: var(--dsw-alias-label-primary); +} + +.emptyHint { + margin: 4px 0 0; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.list { + display: flex; + flex-direction: column; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.change { + min-width: 0; + border: 0.5px solid var(--dsw-alias-border-l2); + border-radius: var(--dsw-radius-md); + background: var(--dsw-alias-bg-layer-1); +} + +/* The whole summary is the expansion toggle. */ +.changeHead { + display: flex; + flex-direction: column; + gap: 4px; + box-sizing: border-box; + width: 100%; + min-width: 0; + padding: 10px 10px 10px 12px; + border: 0; + border-radius: var(--dsw-radius-md); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.changeHead:hover { + background: var(--dsw-alias-interactive-bg-hover); +} + +.changeHead:focus-visible { + outline: none; + box-shadow: inset 0 0 0 2px var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); +} + +.headRow { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-width: 0; +} + +/* Lifecycle tone of the change request glyph, which draws in the current color. */ +.glyph { + display: inline-flex; + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.glyph[data-tone='open'] { + color: var(--dsw-alias-state-success-primary); +} + +.glyph[data-tone='merged'] { + color: var(--dsw-alias-state-business-primary); +} + +.glyph[data-tone='closed'] { + color: var(--dsw-alias-state-error-primary); +} + +.changeTitle { + flex: 1; + min-width: 0; + overflow: hidden; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chevron { + flex: none; + color: var(--dsw-alias-label-tertiary); + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(180deg); +} + +/* Number, branch, and status lines align with the title past the 15px glyph and its 8px gap. */ +.metaRow, +.summary { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; + padding-left: 23px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.summary { + flex-wrap: wrap; + gap: 4px 10px; +} + +.code { + font-family: var(--ds-font-family-code); +} + +.ellipsis { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.diff { + display: inline-flex; + gap: 6px; + font-family: var(--ds-font-family-code); +} + +.added { + color: var(--dsw-alias-state-success-primary); +} + +.deleted { + color: var(--dsw-alias-state-error-primary); +} + +.ci { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--dsw-alias-label-secondary); +} + +.ci[data-tone='error'], +.flag[data-flag='changesRequested'], +.flag[data-flag='conflicting'] { + color: var(--dsw-alias-state-error-primary); +} + +.flag { + color: var(--dsw-alias-label-tertiary); +} + +.finished { + color: var(--dsw-alias-label-secondary); +} + +.finished[data-tone='merged'] { + color: var(--dsw-alias-state-business-primary); +} + +/* Read failure or unsupported host below the toggle, with its one action. */ +.notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 0 10px 10px 35px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.details { + display: flex; + flex-direction: column; + padding: 4px 4px 6px; + border-top: 0.5px solid var(--dsw-alias-border-l2); +} + +.linkRow { + display: flex; + align-items: center; + gap: 2px; +} + +.linkRow .item { + flex: 1; +} + +/* One detail row: a 15px glyph, the label, and a right-aligned value. */ +.item { + display: flex; + align-items: center; + gap: 10px; + box-sizing: border-box; + width: 100%; + min-width: 0; + min-height: 34px; + padding: 0 10px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + text-align: left; +} + +button.item { + cursor: pointer; +} + +button.item:hover, +button.item:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +.item svg { + flex: none; +} + +.item[data-tone='error'] { + color: var(--dsw-alias-state-error-primary); +} + +button.item[data-tone='error']:hover, +button.item[data-tone='error']:focus-visible { + background: var(--dsw-alias-interactive-bg-hover-danger); +} + +.value { + min-width: 0; + margin-left: auto; + overflow: hidden; + color: var(--dsw-alias-label-secondary); + text-overflow: ellipsis; + white-space: nowrap; +} + +.value[data-tone='error'] { + color: var(--dsw-alias-state-error-primary); +} + +.separator { + height: 0.5px; + margin: 4px 6px; + background: var(--dsw-alias-border-l2); +} + +.checks { + display: flex; + flex-direction: column; + margin: 0 6px 4px 34px; + padding: 2px 0; + border-left: 0.5px solid var(--dsw-alias-border-l2); + list-style: none; +} + +.check { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 30px; + padding: 0 10px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-primary); + font: inherit; + font-size: 12px; + text-align: left; +} + +button.check { + cursor: pointer; +} + +button.check:hover, +button.check:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); +} + +.check svg { + flex: none; +} + +.check .value { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.pass { + color: var(--dsw-alias-state-success-primary); +} + +.fail { + color: var(--dsw-alias-state-error-primary); +} + +.truncated { + padding: 4px 10px; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; +} + +.titleIcon { + flex: none; + color: var(--dsw-alias-label-secondary); +} + +.titleText { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx new file mode 100644 index 0000000000..8e24f334b1 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx @@ -0,0 +1,176 @@ +/** Body of the forge sidebar tab: where the Session works and the pull requests it created or pushed. */ + +import { useEffect, useState, type ReactNode } from 'react' +import { + Button, IconBranchOutlineRegular, IconRefreshOutlineRegular, StateDot, Tooltip, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { ForgePanelChangeRequest, ForgePanelState } from '../types.ts' +import { ChangeRequestCard, type ChangeRequestActions } from './ChangeRequestCard.tsx' +import type { ForgePanelKey, NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './ForgeTab.module.css' + +/** Business face of the tab body. */ +export interface ForgeTabInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } + /** Open the stream again after it ended. */ + readonly reconnect: () => void + /** Reinspect the checkout, probe sign-in, and poll the change requests. */ + readonly refresh: () => void + /** Remove a change request from the Session, offering to undo it. */ + readonly remove: (entry: ForgePanelChangeRequest) => void + /** Open a URL outside the app. */ + readonly openLink: (url: string) => void + /** Copy text to the clipboard and report the outcome. */ + readonly copyText: (text: string) => void + /** Show one notice. */ + readonly report: (key: ForgePanelKey) => void + /** Record whether the tab is visible, for the header entry's pressed state. */ + readonly reportVisible: (visible: boolean) => void +} + +/** Full props of the tab body. */ +export type ForgeTabProps = + Pick, 'useTabInfo' | 'inputActions'> + & InjectFace + & PropsLocale + +type Translate = ForgeTabProps['t'] + +function Checkout({ state, t }: { readonly state: ForgePanelState; readonly t: Translate }): ReactNode { + const checkout = state.checkout + if (checkout.state === 'pending') return null + if (checkout.state === 'none') return
    {t('checkout.none')}
    + if (checkout.state === 'failed') { + return ( + +
    {t('checkout.failed')}
    +
    + ) + } + const created = state.worktree.created + const worktree = created !== null && created.path === checkout.checkout.root ? created : null + const branch = worktree?.branch ?? checkout.checkout.branch + return ( + +
    + + {t(worktree === null ? 'checkout.branch' : 'checkout.worktree')} + {branch ?? t('checkout.detached')} + + {worktree === null + ? t('checkout.current') + : t('checkout.basedOn', { base: worktree.from ?? worktree.baseCommit.slice(0, 7) })} + +
    +
    + ) +} + +function SignIn({ state, copyText, t }: { + readonly state: ForgePanelState + readonly copyText: (text: string) => void + readonly t: Translate +}): ReactNode { + const { repository, readiness } = state + if (repository === null || readiness === null || readiness.state === 'ready') return null + const host = repository.host + if (readiness.state === 'unavailable') { + return
    {t('signin.unavailable', { host, message: readiness.message ?? '' })}
    + } + const command = repository.forge === 'github' ? `gh auth login --hostname ${host}` : undefined + return ( +
    + {t('signin.title', { host })} + {command === undefined + ? readiness.message !== null && {readiness.message} + : ( + <> + {command} + + + )} +
    + ) +} + +/** + * Render the forge tab of one Session. + * @param props - tab information, composer actions, panel snapshot hook, actions, and copy. + * @returns the tab body. + */ +export function ForgeTab({ + useTabInfo, inputActions, usePanel, reconnect, refresh, remove, openLink, copyText, report, reportVisible, t, +}: ForgeTabProps): ReactNode { + const snapshot = usePanel(current => current) + const visible = useTabInfo().tab.visible + const [expanded, setExpanded] = useState(null) + useEffect(() => { + reportVisible(visible) + }, [reportVisible, visible]) + useEffect(() => () => { reportVisible(false) }, [reportVisible]) + const state = snapshot.state + const disconnected = snapshot.disconnected && ( +
    + {t('stream.failed')} + +
    + ) + if (state === null) return
    {disconnected || }
    + const actions: ChangeRequestActions = { + openLink, + copyLink: copyText, + retry: refresh, + addToChat: (url) => { + if (!inputActions.insertText(`${url} `, inputActions.captureInsertion())) report('toast.chatFailed') + }, + remove, + } + const now = Date.now() + const count = state.changeRequests.length + return ( +
    + {disconnected} + +
    +
    +

    {t('changes.title', { count })}

    + + + +
    + + {count === 0 + ? ( +
    +

    {t('changes.empty')}

    +

    {t('changes.emptyHint')}

    +
    + ) + : ( +
      + {state.changeRequests.map(entry => ( + { setExpanded(expanded === entry.url ? null : entry.url) }} + now={now} + actions={actions} + t={t} + /> + ))} +
    + )} +
    +
    + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx new file mode 100644 index 0000000000..cd3a10d5de --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/ForgeTabTitle.tsx @@ -0,0 +1,21 @@ +/** Chip title of the forge tab: the branch glyph and the localized tab name. */ + +import type { ReactNode } from 'react' +import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { NS } from './locales.ts' +import css from './ForgeTab.module.css' + +/** + * Render the tab chip title in the current UI language. + * @param props - forge panel copy. + * @returns the glyph followed by the tab name. + */ +export function ForgeTabTitle({ t }: PropsLocale): ReactNode { + return ( + <> + + {t('tab.title')} + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css b/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css new file mode 100644 index 0000000000..5d6fffdf1a --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/HeaderButton.module.css @@ -0,0 +1,35 @@ +/* Icon-only header entry: the 28px round target of the header's other icon + buttons, with the status dot at the glyph's top-right corner. */ +.trigger { + position: relative; + display: inline-flex; + flex: none; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 28px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); +} + +/* The forge tab is visible. */ +.trigger[aria-pressed='true'] { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-active); +} + +.dot { + position: absolute; + top: 6px; + right: 6px; +} diff --git a/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx b/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx new file mode 100644 index 0000000000..cd265a9f58 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/HeaderButton.tsx @@ -0,0 +1,67 @@ +/** Session header entry that shows or hides the forge tab and marks pull requests that need attention. */ + +import type { ReactNode } from 'react' +import { IconBranchOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import { needsAttention } from './status.ts' +import css from './HeaderButton.module.css' + +/** Business face of the header entry. */ +export interface HeaderButtonInjected { + /** Panel snapshot and tab visibility of the Session; the renderer binds them as `usePanel` and `useVisible`. */ + readonly hooks: { readonly panel: HostObservable; readonly visible: HostObservable } + /** + * Hide the forge tab when it is visible, otherwise open or reveal it. + * @param visible - whether the tab is visible now. + */ + readonly toggle: (visible: boolean) => void +} + +/** Full props of the header entry. */ +export type HeaderButtonProps = InjectFace & PropsLocale + +/** + * Show the entry once the Session has bound change requests or its directory + * is known to be inside a Git checkout: after an inspection found one or + * failed, or while a later inspection runs for a directory whose repository + * is already known. + * @param snapshot - panel snapshot. + * @returns whether the entry shows. + */ +function shown(snapshot: PanelSnapshot): boolean { + const state = snapshot.state + if (state === null) return false + return state.changeRequests.length > 0 || state.checkout.state === 'ready' || state.checkout.state === 'failed' + || (state.checkout.state === 'pending' && state.repository !== null) +} + +/** + * Render the header entry. + * @param props - panel snapshot and visibility hooks, the toggle, and copy. + * @returns the entry, or nothing outside a Git checkout without bound change requests. + */ +export function HeaderButton({ usePanel, useVisible, toggle, t }: HeaderButtonProps): ReactNode { + const visible = usePanel(shown) + const attention = usePanel(snapshot => snapshot.state?.changeRequests.some(needsAttention) === true) + const pressed = useVisible(current => current) + if (!visible) return null + const label = t(attention ? 'header.attention' : 'header.label') + return ( + + + + ) +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css new file mode 100644 index 0000000000..26b0d3a374 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.module.css @@ -0,0 +1,220 @@ +/* Worktree creation card in the composer context stack: the dock column and + panel surface of the Goal, Todo, and Queue cards. */ +.dock { + box-sizing: border-box; + width: calc( + 100% - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-side-clearance) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) - + var(--dsh-composer-dock-inset) + ); + margin: 0 auto; +} + +.card { + position: relative; + isolation: isolate; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; + max-width: calc(var(--dsh-composer-card-max-width) - 4 * var(--dsh-composer-dock-inset)); + margin: 0 auto; + padding: 10px 8px 6px 12px; + border: 0; + --dsw-elevation-stroke-color: var(--dsw-alias-border-l1); + border-radius: var(--dsw-radius-md); + box-shadow: var(--dsw-elevation-panel); + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 20px; + /* Elevated surface, as the menu background below makes it: the failure text + scrolls once it reaches its height cap, so the thumb takes the l2 pair. + Declared on the card, whose custom properties inherit down to the box that + scrolls (see ui-theme styles/scrollbar.css for the rebinding contract). */ + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); +} + +.card::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: inherit; + background: var(--dsw-specific-menu); + backdrop-filter: var(--dsw-menu-backdrop-filter); + content: ''; + pointer-events: none; +} + +.header { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + padding-right: 4px; +} + +.glyph { + flex: none; + color: var(--dsw-alias-label-tertiary); +} + +.warning { + flex: none; + color: var(--dsw-alias-state-warn-primary); +} + +.title { + flex: none; + font-weight: 500; +} + +.branch, +.code { + min-width: 0; + overflow: hidden; + color: var(--dsw-alias-label-tertiary); + font-family: var(--ds-font-family-code); + font-size: 12px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.steps { + display: flex; + flex-direction: column; + gap: 2px; + margin: 0; + padding: 0 0 0 22px; + list-style: none; +} + +.step { + display: flex; + align-items: center; + gap: 8px; + min-height: 24px; + color: var(--dsw-alias-label-secondary); +} + +.step[data-state='running'] { + color: var(--dsw-alias-label-primary); +} + +.step[data-state='failed'] { + color: var(--dsw-alias-state-error-primary); +} + +.step svg { + flex: none; +} + +.done { + color: var(--dsw-alias-state-success-primary); +} + +.failed { + color: var(--dsw-alias-state-error-primary); +} + +/* An empty ring for a step that has not started, the size of the step glyphs. */ +.waiting { + flex: none; + box-sizing: border-box; + width: 14px; + height: 14px; + border: 1.5px solid var(--dsw-alias-label-dimmed); + border-radius: 50%; + corner-shape: round; +} + +.details { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 2px 12px; + margin: 2px 0 0 22px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.details dt { + color: var(--dsw-alias-label-tertiary); +} + +.details dd { + margin: 0; +} + +.message { + max-height: 120px; + overflow-y: auto; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; +} + +.disclosure { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 6px; + border: 0; + border-radius: var(--dsw-radius-sm); + background: transparent; + color: var(--dsw-alias-label-tertiary); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.disclosure:hover, +.disclosure:focus-visible { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); +} + +.chevron, +.chevronOpen { + flex: none; + transition: transform 120ms ease; +} + +.chevronOpen { + transform: rotate(90deg); +} + +.actions { + display: inline-flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 4px; + margin-left: auto; +} + +/* Explanation and restore failure under the header of the removed-worktree card. */ +.note, +.error { + margin: 0 0 0 22px; + color: var(--dsw-alias-label-secondary); + font-size: 12px; + line-height: 18px; +} + +.error { + color: var(--dsw-alias-state-error-primary); + overflow-wrap: anywhere; +} diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx new file mode 100644 index 0000000000..911f08f5d4 --- /dev/null +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx @@ -0,0 +1,210 @@ +/** Card above the composer while a Session's worktree is created, after a failed creation, and when its worktree is gone. */ + +import { useState, type ReactNode } from 'react' +import { + Button, IconBranchOutlineRegular, IconCheckOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, StateDot, +} from '@deepseek-ai/dsh-client-ui-primitives' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ForgeMissingWorktree, ForgePanelWorktree, ForgeSessionWorktree } from '../types.ts' +import type { ForgePanelKey, NS } from './locales.ts' +import type { PanelSnapshot } from './source.ts' +import css from './WorktreeProgress.module.css' + +/** Business face of the card. */ +export interface WorktreeProgressInjected { + /** Panel snapshot of the Session; the renderer binds it as `usePanel`. */ + readonly hooks: { readonly panel: HostObservable } + /** Stop the creation that holds the Session's message, which returns the message to the composer. */ + readonly stop: () => void + /** Withdraw the worktree request after a failed creation, so the message can go without a worktree. */ + readonly withdraw: () => void + /** + * Check out the removed worktree again. + * @returns the failure text, or undefined once it is restored. + */ + readonly restore: () => Promise + /** Move the Session back to the source checkout of its removed worktree. */ + readonly leave: () => void +} + +/** Full props of the card. */ +export type WorktreeProgressProps = InjectFace & PropsLocale + +type Translate = WorktreeProgressProps['t'] + +type StepState = 'done' | 'running' | 'waiting' | 'failed' + +const STEPS: readonly { readonly phase: 'prepare' | 'checkout'; readonly label: ForgePanelKey }[] = [ + { phase: 'prepare', label: 'creation.prepare' }, + { phase: 'checkout', label: 'creation.checkout' }, +] + +function stepState(current: 'prepare' | 'checkout', failed: boolean, phase: 'prepare' | 'checkout'): StepState { + if (phase === current) return failed ? 'failed' : 'running' + return phase === 'prepare' ? 'done' : 'waiting' +} + +function StepIcon({ state }: { readonly state: StepState }): ReactNode { + switch (state) { + case 'done': return + case 'running': return + case 'failed': return + case 'waiting': return
    @@ -481,7 +487,9 @@ export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, t } : } )} - {expanded && snapshot !== null &&
    } + {expanded && snapshot !== null && ( +
    + )} {expanded && snapshot !== null && } ) diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 68d329eafa..0e8d5f2a32 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -89,6 +89,7 @@ export const zh = { 'change.mergeNote.merge': '保留全部提交,并新增一个合并提交', 'change.mergeNote.rebase': '把提交逐个变基到 {base}', 'change.mergeConfirm': '确认合并', + 'change.mergeChanged': '提交已更新,请重新检查后再打开合并菜单', 'diff.tab': '变更', 'diff.tabNumbered': '#{number} 的变更', 'diff.scope.branch': '分支', @@ -290,6 +291,7 @@ export const en: Record = { 'change.mergeNote.merge': 'Keep every commit and add a merge commit', 'change.mergeNote.rebase': 'Replay each commit onto {base}', 'change.mergeConfirm': 'Confirm merge', + 'change.mergeChanged': 'New commits arrived. Review them before reopening the merge menu.', 'diff.tab': 'Changes', 'diff.tabNumbered': 'Changes in #{number}', 'diff.scope.branch': 'Branch', diff --git a/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx b/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx index 6f02cd9dd4..e6068789d0 100644 --- a/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx @@ -448,6 +448,25 @@ describe('ChangeRequestCard actions', () => { expect(merge).toHaveBeenCalledOnce() }) + it('requires a new confirmation after the head changes while the menu is open', async () => { + const merge = vi.fn>(async () => {}) + const actions: ChangeRequestActions = { openLink: vi.fn(), copyLink: vi.fn(), retry: vi.fn(), merge } + const view = (headSha: string) => + const rendered = render(view(head)) + const button = screen.getByRole('button', { name: en['change.merge'] }) + fireEvent.click(button) + rendered.rerender(view('c'.repeat(40))) + const changed = screen.getByRole('menuitem', { name: en['change.mergeChanged'] }) + expect(changed.hasAttribute('disabled')).toBe(true) + fireEvent.click(changed) + expect(merge).not.toHaveBeenCalled() + fireEvent.click(button) + fireEvent.click(button) + await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: en['change.mergeConfirm'] })) }) + expect(merge).toHaveBeenCalledWith(expect.anything(), { method: 'squash', headSha: 'c'.repeat(40) }) + }) + it('falls back to the usual method once the repository stops allowing the chosen one', async () => { const merge = vi.fn<(entry: ForgePanelChangeRequest, request: MergeRequest) => Promise>(async () => {}) const actions: ChangeRequestActions = { openLink: vi.fn(), copyLink: vi.fn(), retry: vi.fn(), merge } From fa2dbbd8fd774295396a4fd3a4e426e5d675f668 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:57:11 +0800 Subject: [PATCH 25/32] fix(forge-panel): format change counts in the selected locale --- .../client-ui-forge/README.i18n.yaml | 4 +-- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../src/client/ChangeRequestCard.tsx | 26 +++++++++++-------- .../client-ui-forge/src/client/ForgeTab.tsx | 10 +++++-- .../client-ui-forge/src/client/mount.ts | 6 ++++- .../tests/change-request-card.client.spec.tsx | 23 +++++++++++++--- .../tests/forge-tab.client.spec.tsx | 1 + 8 files changed, 53 insertions(+), 21 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 86c26f0c86..b28b4f1947 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: ae3fe6a8a396c9b8 - zh: cf8770f1beb498e5 + en: 10cb328b8b92f077 + zh: 473121a22ce76dfb /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: e1a1e0429e88ed5f zh: 5db056d99cd9ef41 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index b154b4d522..70b4f540d4 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -31,7 +31,7 @@ Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it com - **Creation card** — while the worktree is created, a card above the composer shows the new branch and its two steps, creating the branch and checking out files; More details adds the branch and base. Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting, and the card shows the reason of a failure: Retry tries again with a new branch name, and Continue without a worktree withdraws the request so the message goes on in the current checkout. - **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph. - **Header button** — appears when the Session has bound change requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open change request with failed checks, requested changes, or conflicts. -- **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. +- **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing. - **Local changes card** — while the `branch` scope changes files and no open change request has the checked-out branch as its head, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered once the code host repository is recognized, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent. - **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. A local view shows Branch by default: the working tree and untracked files against the fork point, which is the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 5c9f4b0f43..bdc0811fe7 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" - **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。 - **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。 - **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。 -- **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 +- **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。 - **变更卡片**——`branch` 范围有文件变更、且没有以当前检出分支为源分支的打开中变更请求时,变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库后,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。 - **变更视图**——展开的变更请求卡片上的“变更”一行与变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地变更默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更;分叉点是 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。 diff --git a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx index f242081624..615d637e18 100644 --- a/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx +++ b/packages/experimental/client-ui-forge/src/client/ChangeRequestCard.tsx @@ -55,6 +55,8 @@ export interface ChangeRequestCardProps { readonly now: number /** Card actions. */ readonly actions: ChangeRequestActions + /** Active UI language for numeric formatting. */ + readonly locale: string /** Forge panel copy. */ readonly t: Translate } @@ -99,21 +101,22 @@ function checkTime(check: ChangeRequestCheck, t: Translate): string { return elapsed === undefined ? '' : formatDuration(elapsed, t) } -function DiffSize({ snapshot }: { readonly snapshot: ChangeRequestSnapshot }): ReactNode { +function DiffSize({ snapshot, locale }: { readonly snapshot: ChangeRequestSnapshot; readonly locale: string }): ReactNode { return ( - {`+${snapshot.additions.toLocaleString('en-US')}`} - {`−${snapshot.deletions.toLocaleString('en-US')}`} + {`+${snapshot.additions.toLocaleString(locale)}`} + {`−${snapshot.deletions.toLocaleString(locale)}`} ) } /** The changed lines, as a row that opens the changes view when the provider serves the change request. */ -function ChangesRow({ entry, snapshot, name, actions, t }: { +function ChangesRow({ entry, snapshot, name, actions, locale, t }: { readonly entry: ForgePanelChangeRequest readonly snapshot: ChangeRequestSnapshot readonly name: string readonly actions: ChangeRequestActions + readonly locale: string readonly t: Translate }): ReactNode { const { viewChanges } = actions @@ -121,7 +124,7 @@ function ChangesRow({ entry, snapshot, name, actions, t }: { <> {t('change.row.changes')} - + ) if (viewChanges === undefined || entry.ref === null) return
    {parts}
    @@ -131,8 +134,8 @@ function ChangesRow({ entry, snapshot, name, actions, t }: { className={clsx(css.row, css.rowButton)} aria-label={t('change.viewChangesAria', { name, - added: snapshot.additions.toLocaleString('en-US'), - deleted: snapshot.deletions.toLocaleString('en-US'), + added: snapshot.additions.toLocaleString(locale), + deleted: snapshot.deletions.toLocaleString(locale), })} onClick={() => { viewChanges(entry) }} > @@ -364,15 +367,16 @@ function Primary({ entry, snapshot, name, actions, t }: { ) } -function Details({ entry, snapshot, name, now, actions, t }: { +function Details({ entry, snapshot, name, now, actions, locale, t }: { readonly entry: ForgePanelChangeRequest readonly snapshot: ChangeRequestSnapshot readonly name: string readonly now: number readonly actions: ChangeRequestActions + readonly locale: string readonly t: Translate }): ReactNode { - const changes = + const changes = if (snapshot.state !== 'open') { return (
    @@ -414,7 +418,7 @@ function Details({ entry, snapshot, name, now, actions, t }: { * @param props - change request, expansion, current time, actions, and copy. * @returns the card. */ -export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, t }: ChangeRequestCardProps): ReactNode { +export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, locale, t }: ChangeRequestCardProps): ReactNode { const snapshot = entry.snapshot const number = numberOf(entry) const url = snapshot?.url ?? entry.url @@ -488,7 +492,7 @@ export function ChangeRequestCard({ entry, expanded, onToggle, now, actions, t }
    )} {expanded && snapshot !== null && ( -
    +
    )} {expanded && snapshot !== null && } diff --git a/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx index 32a0a74659..55e115fadc 100644 --- a/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx +++ b/packages/experimental/client-ui-forge/src/client/ForgeTab.tsx @@ -23,7 +23,11 @@ import css from './ForgeTab.module.css' /** Business face of the tab body. */ export interface ForgeTabInjected { /** Panel snapshot of the Session and the Host facts; the renderer binds them as `usePanel` and `useHost`. */ - readonly hooks: { readonly panel: HostObservable; readonly host: HostObservable } + readonly hooks: { + readonly panel: HostObservable + readonly host: HostObservable + readonly locale: HostObservable + } /** Open the stream again after it ended. */ readonly reconnect: () => void /** @@ -151,11 +155,12 @@ function SignIn({ state, copyText, t }: { * @returns the tab body. */ export function ForgeTab({ - useTabInfo, sessionId, usePanel, useHost, reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, copyPath, + useTabInfo, sessionId, usePanel, useHost, useLocale, reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, copyPath, reportVisible, createChangeRequest, t, }: ForgeTabProps): ReactNode { const snapshot = usePanel(current => current) const home = useHost(facts => facts.home) + const locale = useLocale(value => value) const { tab } = useTabInfo() const visible = tab.visible const [chosen, setChosen] = useState | null>(null) @@ -234,6 +239,7 @@ export function ForgeTab({ onToggle={() => { toggle(entry.url) }} now={now} actions={actions} + locale={locale} t={t} /> ))} diff --git a/packages/experimental/client-ui-forge/src/client/mount.ts b/packages/experimental/client-ui-forge/src/client/mount.ts index adb2908c98..a31164e9cb 100644 --- a/packages/experimental/client-ui-forge/src/client/mount.ts +++ b/packages/experimental/client-ui-forge/src/client/mount.ts @@ -132,10 +132,14 @@ function registerUi(ctx: Context): void { notices.report('toast.restored', { tone: 'success' }) return undefined } + const locale: HostObservable = { + getSnapshot: () => ctx.locale.getLocale().active, + subscribe: listener => ctx.locale.subscribe(listener), + } const tabFace = (sessionId: SessionId): ForgeTabInjected => { const store = storesOf(sessionId) return { - hooks: { panel: store.panel.hooks.panel, host }, + hooks: { panel: store.panel.hooks.panel, host, locale }, reconnect: store.panel.reconnect, refresh: async () => { settled(await ctx.remote.forgePanel.refresh(sessionId), 'toast.refreshFailed') diff --git a/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx b/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx index e6068789d0..a524b38b2e 100644 --- a/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/change-request-card.client.spec.tsx @@ -27,7 +27,7 @@ function card(value: ForgePanelChangeRequest, options: { expanded?: boolean; act const actions: ChangeRequestActions = { openLink, copyLink, retry, remove, ...options.actions } const view = render(
      - +
    , ) const item = view.container.querySelector('[data-forge-change]') @@ -451,7 +451,7 @@ describe('ChangeRequestCard actions', () => { it('requires a new confirmation after the head changes while the menu is open', async () => { const merge = vi.fn>(async () => {}) const actions: ChangeRequestActions = { openLink: vi.fn(), copyLink: vi.fn(), retry: vi.fn(), merge } - const view = (headSha: string) => const rendered = render(view(head)) const button = screen.getByRole('button', { name: en['change.merge'] }) @@ -472,7 +472,7 @@ describe('ChangeRequestCard actions', () => { const actions: ChangeRequestActions = { openLink: vi.fn(), copyLink: vi.fn(), retry: vi.fn(), merge } const view = (methods: MergeRequest['method'][]) => (
      - { expect(b.item.querySelector('[data-forge-check]')?.getAttribute('data-forge-check')).toBe('neutral') }) }) + + +describe('change request number formatting', () => { + it('formats visible and accessible line counts with the selected UI language', () => { + const value = entry(7, snapshot(7, { additions: 1234, deletions: 5678 })) + const actions: ChangeRequestActions = { openLink: vi.fn(), copyLink: vi.fn(), retry: vi.fn(), viewChanges: vi.fn() } + const view = (locale: string) => + const rendered = render(view('de')) + expect(screen.getByText('+1.234')).toBeTruthy() + expect(screen.getByText('−5.678')).toBeTruthy() + expect(screen.getByRole('button', { name: /1\.234.*5\.678/ })).toBeTruthy() + rendered.rerender(view('en')) + expect(screen.getByText('+1,234')).toBeTruthy() + expect(screen.getByRole('button', { name: /1,234.*5,678/ })).toBeTruthy() + }) +}) diff --git a/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx b/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx index b133a2a8ae..ec800951ab 100644 --- a/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/forge-tab.client.spec.tsx @@ -66,6 +66,7 @@ function tab(state: ForgePanelState | null, options: { disconnected?: boolean; v sessionId: SESSION, usePanel: bindSnapshotSelector(panel), useHost: bindSnapshotSelector(host), + useLocale: bindSnapshotSelector(createSnapshotStore('en')), reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, copyPath, reportVisible, createChangeRequest, t, } From ccf509205f9478480f1c770fd2167466d7b8cd8c Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:57:12 +0800 Subject: [PATCH 26/32] fix(forge-panel): release cached state with its Session scope --- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../client-ui-forge/src/client/mount.ts | 25 ++++--- .../tests/mount.client.spec.ts | 65 +++++++++++++++---- 5 files changed, 72 insertions(+), 26 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index b28b4f1947..7c75091d4c 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: 10cb328b8b92f077 zh: 473121a22ce76dfb /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: e1a1e0429e88ed5f - zh: 5db056d99cd9ef41 + en: 7600cf106bd22e53 + zh: 8ee863b3967d99f0 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 70b4f540d4..0b1aa3e1a1 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -55,7 +55,7 @@ Each inspection also decides whether the worktree is missing: the Session's dire `localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. It runs `diff --no-ext-diff --no-textconv --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state for the next subscriber. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. Its body keeps the scope and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. Its body keeps the scope and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index bdc0811fe7..6a5567cbd5 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -55,7 +55,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主 Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,并为下一个订阅方保留最后的状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。它的正文用 slot store 按标签页保存范围与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。它的正文用 slot store 按标签页保存范围与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-forge/src/client/mount.ts b/packages/experimental/client-ui-forge/src/client/mount.ts index a31164e9cb..b447c954fa 100644 --- a/packages/experimental/client-ui-forge/src/client/mount.ts +++ b/packages/experimental/client-ui-forge/src/client/mount.ts @@ -31,7 +31,7 @@ import { WorktreeProgress, type WorktreeProgressInjected } from './WorktreeProgr import { WorktreeToggle, type WorktreeToggleInjected } from './WorktreeToggle.tsx' /** Services required before the Remote contribution mounts. */ -export const inject = ['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight'] +export const inject = ['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight', 'sessions'] /** Registry id of the forge tab type, also its slot key. */ export const TAB_ID = '@deepseek-ai/dsh-experimental-client-ui-forge' @@ -51,6 +51,7 @@ function openLink(url: string): void { interface SessionStores { readonly panel: PanelSource readonly visibility: VisibilitySource + readonly release: () => void } function registerUi(ctx: Context): void { @@ -59,19 +60,23 @@ function registerUi(ctx: Context): void { const notices = createNoticeSource() const stores = new Map() ctx.effect(() => () => { - for (const store of stores.values()) store.panel.close() + for (const store of [...stores.values()]) store.release() stores.clear() }, 'forge-panel: panel streams') const storesOf = (sessionId: SessionId): SessionStores => { let store = stores.get(sessionId) if (store === undefined) { - store = { - panel: createPanelSource({ - open: signal => ctx.remote.forgePanel.follow(sessionId, signal), - subscribeReset: listener => ctx.on('connection/reset', listener), - }), - visibility: createVisibilitySource(), - } + const scope = ctx.sessions.scope(sessionId) + if (scope === undefined) throw new Error(`forge-panel: Session ${sessionId} is not retained`) + const panel = createPanelSource({ + open: signal => ctx.remote.forgePanel.follow(sessionId, signal), + subscribeReset: listener => ctx.on('connection/reset', listener), + }) + const dispose = scope.effect(() => () => { + panel.close() + stores.delete(sessionId) + }, 'forge-panel: release Session state') + store = { panel, visibility: createVisibilitySource(), release: () => { void dispose() } } stores.set(sessionId, store) } return store @@ -92,7 +97,7 @@ function registerUi(ctx: Context): void { /** Send the request for a change request to the Session as a message from the user. */ const createChangeRequest = (sessionId: SessionId): void => { const text = t('changes.local.prompt') - const conversation = ctx.get('sessions')?.scope(sessionId)?.get('conversation') + const conversation = ctx.sessions.scope(sessionId)?.get('conversation') if (conversation === undefined) { notices.report('toast.promptFailed') return diff --git a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts index dd1a7a43ef..88f85879c3 100644 --- a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts @@ -8,7 +8,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client' -import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' +import type { TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client' import { SessionId } from '@deepseek-ai/dsh-session/types' import { RemoteError, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -150,7 +150,15 @@ async function base() { const unregisterTab = vi.fn() const sidebarRightTabs = { register: vi.fn((_type: { id: string; kind: string; title: () => string }) => unregisterTab) } ctx.provide('sidebarRightTabs', sidebarRightTabs) - const sidebarRight = { openTab: vi.fn(), toggleExpanded: vi.fn() } + const sidebarRight = { openTab: vi.fn(), toggleExpanded: vi.fn(), hide: vi.fn(), + tabsIn: vi.fn(() => [{ id: 'forge-tab' as TabId, kind: TAB_KIND }]) } + const scopes = new Map>() + const sessions = { scope: vi.fn<(id: SessionId) => Context | undefined>((id) => { + let fiber = scopes.get(id) + if (fiber === undefined) { fiber = ctx.plugin(() => {}); scopes.set(id, fiber) } + return fiber.ctx + }) } + ctx.provide('sessions', sessions) ctx.provide('sidebarRight', sidebarRight) await ctx.plugin(SlotRegistry) ctx.slots.register({ @@ -166,7 +174,7 @@ async function base() { 'shell.overlay': { kind: 'list', scope: 'root' }, }, } as never, () => null) - return { ctx, remote, unmount, streams, forgePanel, sidebarRightTabs, unregisterTab, sidebarRight } + return { ctx, remote, unmount, streams, forgePanel, sidebarRightTabs, unregisterTab, sidebarRight, sessions, scopes } } async function fixture() { @@ -220,7 +228,7 @@ async function fixture() { describe('mountForgePanel registration', () => { it('declares the browser services it needs', () => { - expect(inject).toEqual(['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight']) + expect(inject).toEqual(['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight', 'sessions']) }) it('registers the tab types, their bodies and titles, the header button, the worktree option and card, and the toast', async () => { @@ -326,6 +334,40 @@ describe('mountForgePanel Session stores', () => { await f.dispose() }) + it('releases a closed Session and recreates its shared state on reopen', async () => { + const f = await fixture() + const first = f.tab(S1) + first.hooks.panel.subscribe(() => {}) + expect(f.header(S1).hooks.panel).toBe(first.hooks.panel) + f.streams[0]!.push(ready({ changeRequests: [entry(1, snapshot(1))] })) + await settle() + await f.scopes.get(S1)!.dispose() + f.scopes.delete(S1) + expect(f.streams[0]!.signal.aborted).toBe(true) + const next = f.tab(S1) + expect(next.hooks.panel).not.toBe(first.hooks.panel) + expect(next.hooks.panel.getSnapshot().state).toBeNull() + expect(f.header(S1).hooks.panel).toBe(next.hooks.panel) + next.hooks.panel.subscribe(() => {}) + expect(f.streams).toHaveLength(2) + await f.dispose() + }) + + it('publishes the selected language and refuses state for a released Session', async () => { + const f = await fixture() + const locale = f.tab(S1).hooks.locale + const changed = vi.fn() + const unsubscribe = locale.subscribe(changed) + f.ctx.locale.addLanguage({ id: 'de', label: 'Deutsch', fallback: 'en' }) + f.ctx.locale.setLocale('de') + expect(locale.getSnapshot()).toBe('de') + expect(changed).toHaveBeenCalled() + unsubscribe() + f.sessions.scope.mockReturnValue(undefined) + expect(() => f.tab(S2)).toThrow('is not retained') + await f.dispose() + }) + it('shares the tab visibility of a Session with its header entry', async () => { const f = await fixture() const visible = f.header(S1).hooks.visible @@ -626,7 +668,8 @@ describe('mountForgePanel changes view', () => { it('sends the request for a change request to the Session as a message and confirms it', async () => { const f = await fixture() const send = vi.fn(async (_text: string) => {}) - f.ctx.provide('sessions', { scope: (sessionId: SessionId) => sessionId === S1 ? { get: (name: string) => name === 'conversation' ? { send } : undefined } : undefined }) + const scope = f.sessions.scope(S1)! + vi.spyOn(scope, 'get').mockImplementation(name => name === 'conversation' ? { send } as never : undefined) f.changes(S1).createChangeRequest() await settle() const text = send.mock.calls[0]?.[0] @@ -641,14 +684,12 @@ describe('mountForgePanel changes view', () => { await f.dispose() }) - it.each([ - ['without a sessions service', undefined], - ['for a Session without a scope', { scope: () => undefined }], - ['for a Session scope without a conversation', { scope: () => ({ get: () => undefined }) }], - ])('reports that the request cannot be sent %s', async (_label, sessions) => { + it.each(['no scope', 'no conversation'] as const)('reports a prompt failure with %s', async (missing) => { const f = await fixture() - if (sessions !== undefined) f.ctx.provide('sessions', sessions) - f.changes(S1).createChangeRequest() + const view = f.changes(S1) + if (missing === 'no scope') f.sessions.scope.mockReturnValue(undefined) + else vi.spyOn(f.sessions.scope(S1)!, 'get').mockReturnValue(undefined) + view.createChangeRequest() expect(f.notice()).toMatchObject({ key: 'toast.promptFailed', tone: 'failure' }) await f.dispose() }) From 2384f5271975a912924a872b29e8c636dcb9bc0d Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:57:14 +0800 Subject: [PATCH 27/32] fix(forge-panel): hide the actual docked or floating tab --- docs/subsystems/sidebar-right.i18n.yaml | 4 ++-- docs/subsystems/sidebar-right.md | 2 +- docs/subsystems/sidebar-right.zh.md | 2 +- .../client/ui-sidebar-right/README.i18n.yaml | 4 ++-- packages/client/ui-sidebar-right/README.md | 2 +- packages/client/ui-sidebar-right/README.zh.md | 2 +- .../ui-sidebar-right/src/client/service.ts | 16 +++++++++++++ .../tests/service.client.spec.ts | 24 +++++++++++++++++++ .../client-ui-forge/README.i18n.yaml | 4 ++-- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../client-ui-forge/src/client/mount.ts | 6 +++-- .../tests/mount.client.spec.ts | 5 +++- 13 files changed, 60 insertions(+), 15 deletions(-) diff --git a/docs/subsystems/sidebar-right.i18n.yaml b/docs/subsystems/sidebar-right.i18n.yaml index 83cd761e68..01662fe377 100644 --- a/docs/subsystems/sidebar-right.i18n.yaml +++ b/docs/subsystems/sidebar-right.i18n.yaml @@ -15,8 +15,8 @@ en: b7b8a7b411aee94d zh: 7298c20000945d97 /right-sidebar/navigation-ctx-sidebarright: - en: 0f80b5364cbad804 - zh: db81652a5374fad1 + en: c695e9aca92271f2 + zh: 4b4d820c5a16bed1 /right-sidebar/slots-and-owner-props: en: f58dc6ff6b0027a7 zh: 707722fcbd096efc diff --git a/docs/subsystems/sidebar-right.md b/docs/subsystems/sidebar-right.md index 1342a7f938..78bff4d40c 100644 --- a/docs/subsystems/sidebar-right.md +++ b/docs/subsystems/sidebar-right.md @@ -88,7 +88,7 @@ Two opens are the navigation controller, and every way into the column calls one Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`. -`close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws. +`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws. `focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import. diff --git a/docs/subsystems/sidebar-right.zh.md b/docs/subsystems/sidebar-right.zh.md index e7df97d11c..a7ba493452 100644 --- a/docs/subsystems/sidebar-right.zh.md +++ b/docs/subsystems/sidebar-right.zh.md @@ -88,7 +88,7 @@ export function apply(ctx: Context): void { 落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。 -`close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。 +`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。 `focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。 diff --git a/packages/client/ui-sidebar-right/README.i18n.yaml b/packages/client/ui-sidebar-right/README.i18n.yaml index cedfe4f4e4..9ad6755db1 100644 --- a/packages/client/ui-sidebar-right/README.i18n.yaml +++ b/packages/client/ui-sidebar-right/README.i18n.yaml @@ -30,8 +30,8 @@ en: ef2af36136c4a919 zh: 48d992e2c923a130 /deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright: - en: 4519c4633d160c2a - zh: e4019a62532f885a + en: 228794a144cb4065 + zh: b0897365a1dfcd1e /deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain: en: 717b86e05b44ebdd zh: 0f4f694d0ea1d7f8 diff --git a/packages/client/ui-sidebar-right/README.md b/packages/client/ui-sidebar-right/README.md index 01fbf0a30e..ff666c6dd4 100644 --- a/packages/client/ui-sidebar-right/README.md +++ b/packages/client/ui-sidebar-right/README.md @@ -94,7 +94,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r `preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference. -`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. +`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook. `focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection. diff --git a/packages/client/ui-sidebar-right/README.zh.md b/packages/client/ui-sidebar-right/README.zh.md index 96f12af1a6..0fd0e54bb1 100644 --- a/packages/client/ui-sidebar-right/README.zh.md +++ b/packages/client/ui-sidebar-right/README.zh.md @@ -94,7 +94,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包 `preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。 -`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 +`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。 `focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。 diff --git a/packages/client/ui-sidebar-right/src/client/service.ts b/packages/client/ui-sidebar-right/src/client/service.ts index f3d90e7584..df9519d364 100644 --- a/packages/client/ui-sidebar-right/src/client/service.ts +++ b/packages/client/ui-sidebar-right/src/client/service.ts @@ -219,6 +219,11 @@ export interface ISidebarRight { * @param tabId - the tab to close. */ close(tabId: TabId): void + /** + * Hide a tab: close its floating occurrence, or collapse its docked column. + * @param tabId - the tab to hide; missing tabs are unchanged. + */ + hide(tabId: TabId): void /** * The active tab of the active pane. * @returns the record, or `undefined` with no Session on screen. @@ -457,6 +462,17 @@ export class SidebarRightController implements ISidebarRight { this.closeIn(this.require().sessionId, tabId) } + /** + * Hide a tab: close its floating occurrence, or collapse its docked column. + * @param tabId - the tab to hide; missing tabs are unchanged. + */ + hide(tabId: TabId): void { + const layout = this.mountedSurface()?.layout + if (layout?.tabs[tabId] === undefined) return + if (findTabPane(layout, tabId).host === 'float') this.close(tabId) + else if (layout.expanded) this.toggleExpanded() + } + /** * The active tab of the active pane. * @returns the record, or `undefined` without an on-screen surface. diff --git a/packages/client/ui-sidebar-right/tests/service.client.spec.ts b/packages/client/ui-sidebar-right/tests/service.client.spec.ts index 9dd8979cea..6125e1ae7c 100644 --- a/packages/client/ui-sidebar-right/tests/service.client.spec.ts +++ b/packages/client/ui-sidebar-right/tests/service.client.spec.ts @@ -624,11 +624,35 @@ describe('SidebarRightController — a tab\'s own actions', () => { }) }) +describe('SidebarRightController — hiding tabs', () => { + it('collapses a docked tab and closes a floating tab without toggling the dock', () => { + const { controller, publish, layout, tabOf } = harness() + publish() + controller.openResource('dsh-resource://file/session/s-test/a.txt') + const docked = tabOf('a.txt') + controller.hide(docked) + expect(layout().expanded).toBe(false) + expect(layout().tabs[docked]).toBeDefined() + controller.hide(docked) + expect(layout().expanded).toBe(false) + controller.openResource('dsh-resource://file/session/s-test/b.txt') + const floating = tabOf('b.txt') + controller.float(floating) + const expanded = layout().expanded + controller.hide(floating) + expect(layout().tabs[floating]).toBeUndefined() + expect(layout().expanded).toBe(expanded) + controller.hide(floating) + expect(layout().expanded).toBe(expanded) + }) +}) + describe('SidebarRightController — the readable slice', () => { it('answers for the no-session case rather than throwing', () => { const { controller } = harness() expect(controller.isExpanded()).toBe(false) expect(controller.active()).toBeUndefined() + controller.hide('missing' as TabId) }) it('reports the last committed surface', () => { diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 7c75091d4c..07098fd974 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: 10cb328b8b92f077 zh: 473121a22ce76dfb /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 7600cf106bd22e53 - zh: 8ee863b3967d99f0 + en: d90a82cad8ffcf3a + zh: 31597820309849bb /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index 0b1aa3e1a1..b1604bd4c5 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -55,7 +55,7 @@ Each inspection also decides whether the worktree is missing: the Session's dire `localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. It runs `diff --no-ext-diff --no-textconv --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. -The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. Its body keeps the scope and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. +The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes//local` and `dsh-resource://forge-changes//change/`, name the source, and a navigation may name a file to scroll to. Its body keeps the scope and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon. | Source | Responsibility | |---|---| diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 6a5567cbd5..3f849e78d2 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -55,7 +55,7 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主 Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 -浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。它的正文用 slot store 按标签页保存范围与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 +浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes//local` 与 `dsh-resource://forge-changes//change/` 指明来源,导航参数可以指定要滚动到的文件。它的正文用 slot store 按标签页保存范围与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。 | 源码 | 职责 | |---|---| diff --git a/packages/experimental/client-ui-forge/src/client/mount.ts b/packages/experimental/client-ui-forge/src/client/mount.ts index b447c954fa..6f0710fecf 100644 --- a/packages/experimental/client-ui-forge/src/client/mount.ts +++ b/packages/experimental/client-ui-forge/src/client/mount.ts @@ -245,8 +245,10 @@ function registerUi(ctx: Context): void { return { hooks: { panel: store.panel.hooks.panel, visible: store.visibility.hooks.visible }, toggle: (visible) => { - if (visible) ctx.sidebarRight.toggleExpanded() - else ctx.sidebarRight.openTab(TAB_KIND) + if (!visible) { ctx.sidebarRight.openTab(TAB_KIND); return } + const tab = ctx.sidebarRight.tabsIn(sessionId).find(tab => tab.kind === TAB_KIND) + if (tab === undefined) return + ctx.sidebarRight.hide(tab.id) }, } }, diff --git a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts index 88f85879c3..40e902f05a 100644 --- a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts @@ -388,8 +388,11 @@ describe('mountForgePanel Session stores', () => { expect(f.sidebarRight.openTab).toHaveBeenCalledWith(TAB_KIND) expect(f.sidebarRight.toggleExpanded).not.toHaveBeenCalled() f.header(S1).toggle(true) - expect(f.sidebarRight.toggleExpanded).toHaveBeenCalledOnce() + expect(f.sidebarRight.hide).toHaveBeenCalledWith('forge-tab') expect(f.sidebarRight.openTab).toHaveBeenCalledOnce() + f.sidebarRight.tabsIn.mockReturnValue([]) + f.header(S1).toggle(true) + expect(f.sidebarRight.hide).toHaveBeenCalledOnce() await f.dispose() }) }) From 000583c1352616e039de1fde7a6977a6d535de96 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:58:51 +0800 Subject: [PATCH 28/32] fix(forge-panel): retain incomplete worktree recovery across reloads --- docs/config-catalog.md | 2 +- docs/config-catalog.zh.md | 2 +- docs/subsystems/forge.md | 10 ++-- docs/subsystems/forge.zh.md | 10 ++-- .../client-ui-forge/README.i18n.yaml | 8 +-- .../experimental/client-ui-forge/README.md | 7 ++- .../experimental/client-ui-forge/README.zh.md | 7 ++- .../src/client/WorktreeProgress.tsx | 2 +- .../client-ui-forge/src/client/locales.ts | 10 ++-- .../experimental/client-ui-forge/src/index.ts | 4 +- .../experimental/client-ui-forge/src/panel.ts | 47 +++++++++++----- .../experimental/client-ui-forge/src/types.ts | 12 +++-- .../client-ui-forge/src/worktree.ts | 17 ++++-- .../tests/client-fixtures.client.ts | 4 +- .../tests/controller.host.spec.ts | 54 +++++++++++++++++-- .../tests/worktree-progress.client.spec.tsx | 1 + .../tests/worktree.host.spec.ts | 28 ++++++++-- .../extensions/tool-cordis/src/api-catalog.ts | 2 +- 18 files changed, 176 insertions(+), 51 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c6662572a5..1a20985d1a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1033,7 +1033,7 @@ export interface CotTranslationPreferences { ## `@deepseek-ai/dsh-experimental-client-ui-forge` - `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` -- `source`: [`packages/experimental/client-ui-forge/src/types.ts:290`](../packages/experimental/client-ui-forge/src/types.ts) +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:296`](../packages/experimental/client-ui-forge/src/types.ts) ```ts config-catalog /** Host settings of the forge panel Remote. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 8577fec82d..e5b38bd5ba 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1035,7 +1035,7 @@ export interface CotTranslationPreferences { ## `@deepseek-ai/dsh-experimental-client-ui-forge` - `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents` -- `source`: [`packages/experimental/client-ui-forge/src/types.ts:290`](../packages/experimental/client-ui-forge/src/types.ts) +- `source`: [`packages/experimental/client-ui-forge/src/types.ts:296`](../packages/experimental/client-ui-forge/src/types.ts) ```ts config-catalog /** Host settings of the forge panel Remote. */ diff --git a/docs/subsystems/forge.md b/docs/subsystems/forge.md index 5bfd4b09d9..75176a409d 100644 --- a/docs/subsystems/forge.md +++ b/docs/subsystems/forge.md @@ -304,6 +304,10 @@ type WorktreeRecord = | { readonly action: 'cancel' } /** The requested worktree was created and became the Session's working directory. */ | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } + /** Restoration started; the checkout stays unavailable until restoration completes. */ + | { readonly action: 'restoring' } + /** The recorded worktree was completely restored. */ + | { readonly action: 'restored' } ``` ```ts type-equiv @@ -375,10 +379,10 @@ type ForgeWorktreeWaiting = 'creation' | 'restoration' ``` ```ts type-equiv -/** The worktree a Session works in, whose directory no longer exists. */ +/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */ interface ForgeMissingWorktree { - /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ - readonly branchExists: boolean + /** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */ + readonly branchExists: boolean | null } ``` diff --git a/docs/subsystems/forge.zh.md b/docs/subsystems/forge.zh.md index a21212cae8..07da03cef1 100644 --- a/docs/subsystems/forge.zh.md +++ b/docs/subsystems/forge.zh.md @@ -304,6 +304,10 @@ type WorktreeRecord = | { readonly action: 'cancel' } /** The requested worktree was created and became the Session's working directory. */ | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } + /** Restoration started; the checkout stays unavailable until restoration completes. */ + | { readonly action: 'restoring' } + /** The recorded worktree was completely restored. */ + | { readonly action: 'restored' } ``` ```ts type-equiv @@ -375,10 +379,10 @@ type ForgeWorktreeWaiting = 'creation' | 'restoration' ``` ```ts type-equiv -/** The worktree a Session works in, whose directory no longer exists. */ +/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */ interface ForgeMissingWorktree { - /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ - readonly branchExists: boolean + /** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */ + readonly branchExists: boolean | null } ``` diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 07098fd974..4629a2a394 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: 10cb328b8b92f077 zh: 473121a22ce76dfb /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: d90a82cad8ffcf3a - zh: 31597820309849bb + en: 048bc189f7e6c00d + zh: 773046629c9903d4 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b @@ -30,8 +30,8 @@ en: e29ceb229a7c5b11 zh: 6f6364154b43bb94 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work: - en: cd255499149603a8 - zh: d2c80e826e78d83a + en: e3044930521b6509 + zh: f7c3241c4e956938 /deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index b1604bd4c5..b0ef8a980d 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -51,6 +51,9 @@ Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute - A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the host-only `forgeWorktree` projection: requests and withdrawals apply until the first `turn/start` or creation, and the first `created` record of an open request applies until the first `step/start`. `requestWorktree` refuses with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; `cancelWorktree` refuses only with the first and needs no `worktrees` service; both forget a failed attempt. When a message enters the inbox of a Session whose request is open, an `agent/inbox/inserted` listener starts maintenance of the Session's Agent before the delivery wakes it, so the Agent latches the wake and starts the message's turn once that hold ends. The hold makes one attempt with a new branch name, publishing the running stage in the panel state with `held` set and `waiting` as `creation`, and records `created` with the source repository root. After a failed attempt, whose branch, base, failed step, and reason stay in the panel state as `failure`, or after `stopCreation`, the hold waits: `retryWorktree` makes another attempt, refused with `forgePanel/not-waiting` while none waits or one runs, and withdrawing the request lets the message go on in the current checkout. Each message that enters while the Agent is idle and the Session works in its created worktree starts a hold that checks whether the worktree is missing; while it is, the hold waits with `waiting` as `restoration`, and `restoreWorktree` or `continueInProject` runs inside it and then lets the message go on; a check that cannot answer lets the message go at once. The Agent's cancellation and the Remote's stop end a hold, and a message that enters while the Agent runs a turn or other maintenance gets none. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message no hold took; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` run as maintenance of their own while no message waits; `restoreWorktree` publishes its running step as `restoration`, and clears `missing` in the same state that ends it. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`. `markReady` and `merge` act only on a visible binding of the Session that a provider serves, else they refuse with `forgePanel/not-bound`; `merge` passes the head commit the card showed, and a moved head refuses with `forgePanel/stale-head`, while any other refusal is `forgePanel/action-failed` with the host's reason. + +Restoration records `restoring` before mutation and `restored` only after success. Projection version 3 retains incomplete restoration across reloads. Directory existence alone cannot release a waiting message: the checkout root and shared Git directory must match the recorded worktree. + Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. `localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes//HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. It runs `diff --no-ext-diff --no-textconv --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. `localText` reads the new side of one file, from the index through `show :` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails. @@ -103,10 +106,10 @@ None for a new worktree, as its notice belongs to the first request of the Sessi - **Panel-only status** — status is polled only while a surface of this package is mounted for the Session; there are no notifications for checks that finish while none is. - **Inspection triggers** — the checkout is inspected after Session tool results and directory changes; a change made outside the Session appears after Refresh or the next tool result. -- **Creation only** — the panel creates a worktree only before a Session's first step and never removes or prunes one; a failed or cancelled attempt can leave its branch or checkout behind. Branch names come from the Session id and random digits, not from the Session title, which does not exist yet. +- **Retained checkouts** — the panel creates a worktree only before a Session's first step and does not remove existing checkout directories or branches; a failed or cancelled attempt can leave its branch or checkout behind. Branch names come from the Session id and random digits, not from the Session title, which does not exist yet. - **Messages during a turn** — a message that enters while the Agent runs a turn or other maintenance gets no hold: a requested worktree is then created at the first step, where a failure ends the turn without the message, and a removed worktree is not checked, so the working-directory service moves the Session back to the project directory. - **Waiting messages** — while a message waits, the Agent stays idle in maintenance and the composer offers no stop; the card's actions, the Agent's cancellation, or the Remote's stop release it, and callers that wait for the Agent to become idle wait as well. -- **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. +- **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. The [worktree service](../worktree/README.md#use-this-package) validates the recorded target and removes only its missing, unlocked registration. An existing or incomplete directory stays blocked; preserve its files and remove the directory before retrying, or continue in the source project. - **Linked-worktree projects** — the option is unavailable when the project is itself a linked worktree: under workspace-write its Git directory lies outside the writable root, so neither the new branch nor later commits could be written. - **Changes scopes** — deferred: scopes for one commit and for the last turn. The Branch scope needs a remote whose default branch HEAD shares history with, or a worktree the Session created. - **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 3f849e78d2..8d634e5152 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -51,6 +51,9 @@ kind: "package-reference" worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;`cancelWorktree` 只以第一种拒绝,也不需要 `worktrees` 服务;两者都会忘掉失败的尝试。消息进入有未完成请求的 Session 的收件箱时,一个 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Session 所属 Agent 的维护任务,因此 Agent 会暂存唤醒,等这次暂留结束后才开始这条消息的这一轮。暂留使用新分支名进行一次尝试,在面板状态中发布正在进行的阶段,设置 `held`,`waiting` 为 `creation`,并记录带源仓库根目录的 `created`。尝试失败(其分支、基准、失败步骤与原因以 `failure` 留在面板状态中)或经 `stopCreation` 停止后,暂留继续等待:`retryWorktree` 再做一次尝试,没有消息等待或已有尝试进行时以 `forgePanel/not-waiting` 拒绝;撤回请求则让消息在当前检出中继续。Agent 空闲且 Session 在所创建 worktree 中工作时,进入的每条消息都会启动一次暂留,检查该 worktree 是否缺失;缺失期间暂留以 `waiting` 为 `restoration` 等待,`restoreWorktree` 或 `continueInProject` 在暂留内运行,之后放行消息;检查无法给出结果时立即放行。Agent 的取消与 Remote 的停止都会结束暂留;Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖没有被暂留的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。没有消息等待时,`restoreWorktree` 与 `continueInProject` 以各自的维护任务运行;`restoreWorktree` 以 `restoration` 发布正在进行的步骤,并在结束恢复的同一状态中清除 `missing`。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。`markReady` 与 `merge` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;`merge` 带上卡片显示的 head 提交,head 已变化时以 `forgePanel/stale-head` 拒绝,其他拒绝都是带主机原因的 `forgePanel/action-failed`。 + +恢复在修改前记录 `restoring`,仅在成功后记录 `restored`。投影版本 3 在重载后保留未完成的恢复。目录存在本身不能放行等待的消息:检出根目录与公共 Git 目录必须匹配记录的工作树。 + 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes//HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。`localText` 读取一个文件的新版本:“已暂存”经 `show :` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。 @@ -103,10 +106,10 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi - **状态只在面板中轮询**——只有本包为该 Session 挂载了某个界面时才会轮询状态;都未挂载时检查结束不会有通知。 - **检查触发时机**——检出只在 Session 的工具结果与目录变化之后检查;Session 之外的改动要等到刷新或下一个工具结果才会出现。 -- **只负责创建**——面板只在 Session 的第一步之前创建 worktree,从不删除或清理;失败或被取消的尝试可能留下分支或检出。分支名取自 Session id 与随机数字,而不是 Session 标题,因为那时标题还不存在。 +- **保留检出**——面板只在 Session 的第一步之前创建 worktree,不删除已有的检出目录或分支;失败或被取消的尝试可能留下分支或检出。分支名取自 Session id 与随机数字,而不是 Session 标题,因为那时标题还不存在。 - **一轮进行中的消息**——Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留:所请求的 worktree 改为在第一步创建,失败时这一轮结束且不带这条消息;已删除的 worktree 不会被检查,工作目录服务会把 Session 移回项目目录。 - **等待中的消息**——消息等待期间,Agent 以空闲状态处于维护任务中,输入框不提供停止;只有卡片上的操作、Agent 的取消或 Remote 的停止能放行它,等待 Agent 空闲的调用方也会一并等待。 -- **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。 +- **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。[worktree 服务](../worktree/README.zh.md#use-this-package)校验记录的目标,只移除该目标缺失且未锁定的登记。已有或未恢复完成的目录仍会阻止恢复;请先保存其中的文件并移除目录再重试,或在源项目继续。 - **链接 worktree 项目**——项目本身是链接 worktree 时该选项不可用:在 workspace-write 下它的 Git 目录位于可写根之外,新分支与之后的提交都无法写入。 - **变更范围**——待办:单个提交与上一轮的范围。“分支”范围需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree。 - **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。 diff --git a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx index cdb7b5f50c..bc3cc19c71 100644 --- a/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx +++ b/packages/experimental/client-ui-forge/src/client/WorktreeProgress.tsx @@ -235,7 +235,7 @@ function MissingCard({ created, missing, restoration, waiting, restore, leave, t {t('missing.title')} {created.branch} -

      {t(missing.branchExists ? 'missing.kept' : 'missing.recreated', { branch: created.branch })}

      +

      {t(missing.branchExists === null ? 'missing.unknown' : missing.branchExists ? 'missing.kept' : 'missing.recreated', { branch: created.branch })}

      {waiting &&

      {t('missing.waiting')}

      } {error !== null &&

      {error}

      }
      diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index 0e8d5f2a32..f10c40b6d0 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -165,7 +165,8 @@ export const zh = { 'creation.stopped': '已停止创建工作树', 'creation.retry': '重试', 'creation.waiting': '消息会在工作树建好后发送', - 'missing.title': '工作树已被删除', + 'missing.title': '工作树不可用', + 'missing.unknown': '工作树恢复尚未完成,暂时无法确认分支 {branch} 的状态。请排除恢复错误后重试,或在项目目录继续', 'missing.kept': '可以在原位置恢复分支 {branch} 的工作树。删除前未提交的修改无法恢复', 'missing.recreated': '分支 {branch} 也已删除。恢复时会从创建时的提交重建分支,之后的提交和未提交的修改无法恢复', 'missing.restore': '恢复工作树', @@ -176,7 +177,7 @@ export const zh = { 'branch.worktree': '工作树(基于 {base}):{path}', 'branch.linked': '工作树:{path}', 'branch.project': '项目目录:{path}', - 'branch.missing': '工作树已被删除:{path}', + 'branch.missing': '工作树不可用:{path}', 'toast.removed': '已从本会话移除 {name}', 'toast.undo': '撤销', 'toast.undoFailed': '撤销失败', @@ -367,7 +368,8 @@ export const en: Record = { 'creation.stopped': 'Worktree creation stopped', 'creation.retry': 'Retry', 'creation.waiting': 'The message is sent once the worktree exists', - 'missing.title': 'The worktree was removed', + 'missing.title': 'The worktree is unavailable', + 'missing.unknown': 'Worktree restoration is incomplete and branch {branch} cannot be inspected. Resolve the restoration error and retry, or continue in the project.', 'missing.kept': 'The worktree of branch {branch} can be restored at its original location. Uncommitted changes from before the removal cannot be recovered', 'missing.recreated': 'Branch {branch} was removed too. Restoring creates it again at the commit the worktree started from, without later commits or uncommitted changes', 'missing.restore': 'Restore worktree', @@ -378,7 +380,7 @@ export const en: Record = { 'branch.worktree': 'Worktree (from {base}): {path}', 'branch.linked': 'Worktree: {path}', 'branch.project': 'Project directory: {path}', - 'branch.missing': 'Worktree removed: {path}', + 'branch.missing': 'Worktree unavailable: {path}', 'toast.removed': 'Removed {name} from this Session', 'toast.undo': 'Undo', 'toast.undoFailed': 'Undo failed', diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index 951d7bb81e..ca4e4d87c9 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -416,8 +416,8 @@ export class ForgePanelController extends TypertRemoteService { if (worktrees === undefined) throw new RemoteError('forgePanel/worktrees-unavailable', 'No worktree service is mounted', {}) await this.maintain(session, async (agent, work) => { const operation = AbortSignal.any([signal, work]) - if (await this.home.fs.lstat(created.path, undefined, operation) !== undefined) return try { + if (await missingWorktree(this.home, this.config, session, this.worktreeState(session), created.path, operation) === null) return await this.creations.restore(agent, worktrees, created, operation, () => { this.panels.get(session)?.restored() }) } catch (error) { operation.throwIfAborted() @@ -478,7 +478,7 @@ export class ForgePanelController extends TypertRemoteService { const directory = this.home.workingDirectory.get(session) const panel = this.panels.get(session) const missing = panel === undefined - ? await missingWorktree(this.home, this.config, session, this.worktreeState(session).created, directory, signal) + ? await missingWorktree(this.home, this.config, session, this.worktreeState(session), directory, signal) : await panel.checkMissing(signal) return this.home.workingDirectory.get(session) === directory ? missing : null } diff --git a/packages/experimental/client-ui-forge/src/panel.ts b/packages/experimental/client-ui-forge/src/panel.ts index b551b0d41e..0c21c934c4 100644 --- a/packages/experimental/client-ui-forge/src/panel.ts +++ b/packages/experimental/client-ui-forge/src/panel.ts @@ -10,14 +10,14 @@ import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection' import { deadline } from '@deepseek-ai/dsh-timeout' import type {} from '@deepseek-ai/dsh-working-directory' -import { hasBranch, inspectCheckout } from './git.ts' +import { gitIn, hasBranch, inspectCheckout } from './git.ts' import type { GitConfig, Inspection } from './git.ts' import { localChanges } from './local-diff.ts' import type { DiffConfig } from './local-diff.ts' import { remoteOrder, upstreamBranch } from './parse.ts' import type { ForgeCheckoutState, ForgeLocalChanges, ForgeMissingWorktree, ForgePanelChangeRequest, ForgePanelState, ForgePanelWorktree, - ForgeSessionWorktree, ForgeWorktreeBlocker, ForgeWorktreeState, + ForgeWorktreeBlocker, ForgeWorktreeState, } from './types.ts' import { within, WORKTREE_RECORD } from './worktree.ts' import type { WorktreeCreations } from './worktree.ts' @@ -167,14 +167,14 @@ export class SessionPanel { /** * Check again whether the worktree the Session works in is gone, publishing a change. * @param signal - cancellation of the check. - * @returns the missing worktree, or null while it exists or the Session works elsewhere. + * @returns the unavailable worktree, or null for a valid checkout or a Session working elsewhere. * @throws when the filesystem or Git cannot answer. */ async checkMissing(signal: AbortSignal): Promise { const directory = this.current.directory - const created = this.worktreeStates.stateOf(this.session).created + const state = this.worktreeStates.stateOf(this.session) const missing = await missingWorktree( - this.ctx, this.config, this.session, created, directory, AbortSignal.any([signal, this.lifetime.signal]), + this.ctx, this.config, this.session, state, directory, AbortSignal.any([signal, this.lifetime.signal]), ) if (this.current.directory === directory) { this.missing = missing @@ -283,8 +283,8 @@ export class SessionPanel { } let missing: ForgeMissingWorktree | null = null try { - const created = this.worktreeStates.stateOf(this.session).created - missing = await missingWorktree(this.ctx, this.config, this.session, created, directory, this.lifetime.signal) + const state = this.worktreeStates.stateOf(this.session) + missing = await missingWorktree(this.ctx, this.config, this.session, state, directory, this.lifetime.signal) } catch (_error) { // Without an answer from the filesystem or Git the panel offers no restoration. } @@ -372,23 +372,46 @@ function sameWorktree(left: ForgePanelWorktree, right: ForgePanelWorktree): bool * @param ctx - context that provides `fs` and the process services. * @param config - Git limits. * @param session - Session to check. - * @param created - the worktree the Session created, or null. + * @param state - recorded worktree and restoration completion. * @param directory - the Session's current directory. * @param signal - cancellation of the check. - * @returns the missing worktree, or null while it exists or the Session works elsewhere. + * @returns the unavailable worktree, or null for a valid checkout or a Session working elsewhere. * @throws when the filesystem or Git cannot answer. */ export async function missingWorktree( ctx: Context, config: GitConfig, session: Session, - created: ForgeSessionWorktree | null, + state: ForgeWorktreeState, directory: string, signal: AbortSignal, ): Promise { + const created = state.created if (created === null || !within(created.path, directory)) return null - if (await ctx.fs.lstat(created.path, undefined, signal) !== undefined) return null - return { branchExists: await hasBranch(ctx, config, session, created.repositoryRoot, created.branch, signal) } + if (!state.restoring && await ctx.fs.lstat(created.path, undefined, signal) !== undefined) { + const git = gitIn(ctx, config, session, created.path, signal) + const top = await git('rev-parse', ['rev-parse', '--show-toplevel'], [0, 128]) + if (top.exitCode === 0) { + const target = await ctx.fs.resolve(created.path, { signal }) + const root = await ctx.fs.resolve(top.stdout.replace(/\r?\n$/, ''), { cwd: created.path, signal }) + if (root.targetKey === target.targetKey) { + const common = await git('common-dir', ['rev-parse', '--path-format=absolute', '--git-common-dir']) + const source = await gitIn(ctx, config, session, created.repositoryRoot, signal)( + 'common-dir', ['rev-parse', '--path-format=absolute', '--git-common-dir'], + ) + const actual = await ctx.fs.resolve(common.stdout.replace(/\r?\n$/, ''), { cwd: created.path, signal }) + const expected = await ctx.fs.resolve(source.stdout.replace(/\r?\n$/, ''), { cwd: created.repositoryRoot, signal }) + if (actual.targetKey === expected.targetKey) return null + } + } + } + try { + return { branchExists: await hasBranch(ctx, config, session, created.repositoryRoot, created.branch, signal) } + } catch (error) { + signal.throwIfAborted() + if (!state.restoring) throw error + return { branchExists: null } + } } function hostOf(repository: ForgeRepository): string { diff --git a/packages/experimental/client-ui-forge/src/types.ts b/packages/experimental/client-ui-forge/src/types.ts index 4e9493fc52..b4f8de2da7 100644 --- a/packages/experimental/client-ui-forge/src/types.ts +++ b/packages/experimental/client-ui-forge/src/types.ts @@ -49,6 +49,10 @@ export type WorktreeRecord = | { readonly action: 'cancel' } /** The requested worktree was created and became the Session's working directory. */ | { readonly action: 'created'; readonly worktree: ForgeSessionWorktree } + /** Restoration started; the checkout stays unavailable until restoration completes. */ + | { readonly action: 'restoring' } + /** The recorded worktree was completely restored. */ + | { readonly action: 'restored' } /** One open worktree request. */ export interface ForgeWorktreeRequest { @@ -66,6 +70,8 @@ export interface ForgeWorktreeState { readonly locked: boolean /** Whether a step has started; no worktree is created afterwards. */ readonly stepped: boolean + /** Whether a restoration started without a successful completion, including across reloads. */ + readonly restoring: boolean } /** Why the Session's checkout cannot host a new worktree. */ @@ -118,10 +124,10 @@ export interface ForgeWorktreeFailure { */ export type ForgeWorktreeWaiting = 'creation' | 'restoration' -/** The worktree a Session works in, whose directory no longer exists. */ +/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */ export interface ForgeMissingWorktree { - /** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */ - readonly branchExists: boolean + /** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */ + readonly branchExists: boolean | null } /** Worktree facts of one Session in the panel state. */ diff --git a/packages/experimental/client-ui-forge/src/worktree.ts b/packages/experimental/client-ui-forge/src/worktree.ts index 4cc455cdaa..758ce8e184 100644 --- a/packages/experimental/client-ui-forge/src/worktree.ts +++ b/packages/experimental/client-ui-forge/src/worktree.ts @@ -18,7 +18,7 @@ import type { /** Plugin record type of worktree requests, withdrawals, and creations. */ export const WORKTREE_RECORD = 'plugin:client-ui-forge/worktree' -const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false } +const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false, restoring: false } const worktreeSchema: z.ZodType = z.object({ path: z.string().min(1), @@ -32,6 +32,8 @@ const recordSchema: z.ZodType = z.discriminatedUnion('action', [ z.object({ action: z.literal('request'), from: z.string().min(1).nullable() }).strict(), z.object({ action: z.literal('cancel') }).strict(), z.object({ action: z.literal('created'), worktree: worktreeSchema }).strict(), + z.object({ action: z.literal('restoring') }).strict(), + z.object({ action: z.literal('restored') }).strict(), ]) const stateSchema: z.ZodType = z.object({ @@ -39,17 +41,19 @@ const stateSchema: z.ZodType = z.object({ created: worktreeSchema.nullable(), locked: z.boolean(), stepped: z.boolean(), + restoring: z.boolean(), }).strict() /** * The host-only `forgeWorktree` unit. `request` and `cancel` records change * the request until the first `turn/start` or creation; the first `created` * record of an open request applies until the first `step/start`. Records - * outside those periods leave the state unchanged. + * outside those periods leave the state unchanged. Restoration start and completion + * records retain whether the created checkout is unavailable across reloads. */ export const forgeWorktreeProjection = { key: 'forgeWorktree', - stateVersion: 2, + stateVersion: 3, stateSchema, init: () => EMPTY, apply, @@ -101,6 +105,10 @@ function applyRecord(state: ForgeWorktreeState, event: SessionEvent): ForgeWorkt return state.locked || settled || state.request === null ? state : { ...state, request: null } case 'created': return settled || state.stepped || state.request === null ? state : { ...state, created: data.worktree } + case 'restoring': + return !settled || state.restoring ? state : { ...state, restoring: true } + case 'restored': + return !state.restoring ? state : { ...state, restoring: false } /* v8 ignore next -- closed WorktreeRecord action union exhaustiveness guard */ default: return assertNever(data, 'WorktreeRecord.action') } @@ -331,11 +339,14 @@ export class WorktreeCreations { restored: () => void, ): Promise { const session = agent.session + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' }) try { await worktrees.restore(agent, created, signal, (phase) => { this.restorations.set(session, { phase }) this.changed(session) }) + signal.throwIfAborted() + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restored' }) restored() } finally { this.restorations.delete(session) diff --git a/packages/experimental/client-ui-forge/tests/client-fixtures.client.ts b/packages/experimental/client-ui-forge/tests/client-fixtures.client.ts index 1a125e2d58..f7eec7008a 100644 --- a/packages/experimental/client-ui-forge/tests/client-fixtures.client.ts +++ b/packages/experimental/client-ui-forge/tests/client-fixtures.client.ts @@ -140,7 +140,9 @@ export function requested(from: string | null = 'main', overrides: Partial = {}): ForgePanelState { +export function removed( + branchExists: boolean | null = true, waiting = false, overrides: Partial = {}, +): ForgePanelState { return ready({ directory: '/work/repo-dsh', checkout: { state: 'failed', message: 'fatal: cannot change to \'/work/repo-dsh\': No such file or directory' }, diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index e93506ed3e..e6d7973e66 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -9,6 +9,7 @@ import WorktreeService from '@deepseek-ai/dsh-experimental-worktree' import ForgePanelController from '../src/index.ts' import type { Config, ForgePanelState, ForgeSessionWorktree, ForgeWorktreeState } from '../src/index.ts' import { WORKTREE_RECORD } from '../src/worktree.ts' +import { missingWorktree } from '../src/panel.ts' import { bench, branches, commitAt, demo, follow, git, hold, notice, prompt, proposeStep, pullUrl, rejectionOf, repository, scratch, sendPrompt, shellTurn, snapshot, startStep, startTurn, @@ -714,7 +715,7 @@ describe('forgePanel worktree requests', () => { await expect(opened.ctx.forgePanel.requestWorktree(opened.session, 'main', signal)).rejects.toMatchObject({ code: 'forgePanel/session-started' }) await expect(opened.ctx.forgePanel.cancelWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/session-started' }) - expect(worktreeState(opened)).toMatchObject({ request: { from: null }, locked: false, stepped: false }) + expect(worktreeState(opened)).toMatchObject({ request: { from: null }, locked: false, stepped: false, restoring: false }) expect(opened.records).toHaveLength(2) }) @@ -1252,7 +1253,7 @@ describe('forgePanel removed worktrees', () => { await opened.ctx.forgePanel.restoreWorktree(opened.session, signal) - expect(opened.sandbox.calls).toHaveLength(commands) + expect(opened.sandbox.calls.slice(commands).every(call => !call.argv.includes('add'))).toBe(true) expect(await git(created.path, 'symbolic-ref', '--short', 'HEAD')).toBe(created.branch) }) @@ -1600,7 +1601,7 @@ describe('forgePanel lifecycle', () => { await opened.ctx.plugin(ForgePanelController, {}) expect(opened.ctx.sessionProjections.checkpoint(opened.session).forgeWorktree?.val) - .toEqual({ request: { from: null }, created: null, locked: false, stepped: false }) + .toEqual({ request: { from: null }, created: null, locked: false, stepped: false, restoring: false }) }) }) @@ -1642,3 +1643,50 @@ describe('forgePanel configuration', () => { await expect(opened.ctx.plugin(ForgePanelController, config)).rejects.toThrow(message) }) }) + + +describe('worktree recovery and unload', () => { + it('keeps a failed restoration blocked after the panel reloads, despite its leftover directory', async () => { + const { repo } = await project() + const opened = await open(repo, {}, { worktrees: true }) + const created = await startInWorktree(opened) + await rm(created.path, { recursive: true, force: true }) + const occupied = join(repo, '.agents/worktrees/occupied') + await git(repo, 'worktree', 'add', '--force', occupied, created.branch) + await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/restore-failed' }) + await expect(access(created.path)).resolves.toBeUndefined() + expect(worktreeState(opened).restoring).toBe(true) + await opened.panel.dispose() + await opened.ctx.plugin(ForgePanelController, {}) + const follower = reader(opened) + await follower.until(state => state.worktree.missing !== null) + const wakes = opened.agent.wakes + opened.sandbox.failure = new Error('Git temporarily unavailable') + sendPrompt(opened.agent, 'after failure') + await follower.until(state => state.worktree.waiting === 'restoration') + expect(opened.agent.wakes).toBe(wakes) + expect(follower.states.at(-1)?.worktree.missing).toEqual({ branchExists: null }) + opened.sandbox.failure = undefined + await expect(opened.ctx.forgePanel.restoreWorktree(opened.session, signal)).rejects.toMatchObject({ code: 'forgePanel/restore-failed' }) + expect(opened.agent.wakes).toBe(wakes) + await opened.ctx.forgePanel.continueInProject(opened.session, signal) + await opened.agent.whenIdle() + expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo) + }) + + +}) + + +it.each(['empty', 'foreign', 'non-git'] as const)('keeps a recorded worktree unavailable when its directory is %s', async (kind) => { + const { repo } = await project() + const opened = await open(repo, {}, { worktrees: true }) + const created = await startInWorktree(opened) + await rm(created.path, { recursive: true, force: true }) + const path = kind === 'non-git' ? await plain() : created.path + if (kind !== 'non-git') await mkdir(path) + if (kind === 'foreign') await git(path, 'init', '-b', 'other') + const state = { ...worktreeState(opened), created: { ...created, path } } + expect(await missingWorktree(opened.ctx, { gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576 }, + opened.session, state, path, signal)).toEqual({ branchExists: true }) +}) diff --git a/packages/experimental/client-ui-forge/tests/worktree-progress.client.spec.tsx b/packages/experimental/client-ui-forge/tests/worktree-progress.client.spec.tsx index 1057f22f03..71fbdb43b7 100644 --- a/packages/experimental/client-ui-forge/tests/worktree-progress.client.spec.tsx +++ b/packages/experimental/client-ui-forge/tests/worktree-progress.client.spec.tsx @@ -223,6 +223,7 @@ describe('WorktreeProgress waiting after a failed attempt', () => { describe('WorktreeProgress removed worktree', () => { it.each([ + ['cannot be inspected', null, t('missing.unknown', { branch: 'dsh/0a1b2c3d' })], ['still exists', true, t('missing.kept', { branch: 'dsh/0a1b2c3d' })], ['was removed too', false, t('missing.recreated', { branch: 'dsh/0a1b2c3d' })], ])('names the worktree and explains restoring it when its branch %s', (_label, branchExists, note) => { diff --git a/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts b/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts index 9e3085e525..f69636a6af 100644 --- a/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts @@ -29,7 +29,7 @@ const WORKTREE: ForgeSessionWorktree = { } /** The state before any worktree record, turn, or step. */ -const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false } +const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false, restoring: false } /** A live Session whose `forgeWorktree` state a registry folds without the panel Remote. */ async function folded(): Promise<{ session: Session; state: () => ForgeWorktreeState }> { @@ -43,6 +43,24 @@ async function folded(): Promise<{ session: Session; state: () => ForgeWorktreeS } describe('forgeWorktree projection', () => { + it('replays incomplete restoration until an explicit successful completion', async () => { + const { session, state } = await folded() + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' }) + expect(state().restoring).toBe(false) + appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null }) + appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE }) + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' }) + expect(state().restoring).toBe(true) + const checkpoint = state() + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' }) + expect(state()).toBe(checkpoint) + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restored' }) + expect(state().restoring).toBe(false) + const restored = state() + appendPluginRecord(session, WORKTREE_RECORD, { action: 'restored' }) + expect(state()).toBe(restored) + }) + it('starts without a request, a created worktree, a started turn, or a started step', async () => { const { state } = await folded() @@ -94,8 +112,8 @@ describe('forgeWorktree projection', () => { const created = state() startStep(session, 1) - expect(created).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: false }) - expect(state()).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: true }) + expect(created).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: false, restoring: false }) + expect(state()).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: true, restoring: false }) }) it('ignores a created worktree without an open request or after the first step starts', async () => { @@ -116,7 +134,7 @@ describe('forgeWorktree projection', () => { const stepped = state() appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE }) - expect(stepped).toEqual({ request: { from: null }, created: null, locked: true, stepped: true }) + expect(stepped).toEqual({ request: { from: null }, created: null, locked: true, stepped: true, restoring: false }) expect(state()).toBe(stepped) }) @@ -133,7 +151,7 @@ describe('forgeWorktree projection', () => { startTurn(session, 3) expect(locked).toEqual({ ...EMPTY, locked: true }) - expect(stepped).toEqual({ ...EMPTY, locked: true, stepped: true }) + expect(stepped).toEqual({ ...EMPTY, locked: true, stepped: true, restoring: false }) expect(state()).toBe(stepped) }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 224e8f000f..aa09eb4b9d 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -6215,7 +6215,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ForgeMissingWorktree', - declaration: 'export interface ForgeMissingWorktree {\n readonly branchExists: boolean;\n}', + declaration: 'export interface ForgeMissingWorktree {\n readonly branchExists: boolean | null;\n}', }, { name: 'ForgePanelChangeRequest', From 1f54902e3bc12da3b2ee78287a02317b3ae1f3c2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 17:58:53 +0800 Subject: [PATCH 29/32] fix(forge-panel): drain worktree operations before releasing readers --- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 +- .../experimental/client-ui-forge/README.zh.md | 2 +- .../experimental/client-ui-forge/src/index.ts | 32 ++++++++++--- .../client-ui-forge/src/worktree.ts | 47 ++++++++++++++++++- .../tests/controller.host.spec.ts | 46 +++++++++++++++++- .../tests/worktree.host.spec.ts | 10 ++++ 7 files changed, 129 insertions(+), 14 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 4629a2a394..5c5bedb185 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -18,8 +18,8 @@ en: 10cb328b8b92f077 zh: 473121a22ce76dfb /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: - en: 048bc189f7e6c00d - zh: 773046629c9903d4 + en: 0cb26601d077a723 + zh: af04ea90c3d3b704 /deepseek-ai-dsh-experimental-client-ui-forge/further-exploration: en: fa4d8c579ffc49ff zh: 5856ed7469e4585b diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index b0ef8a980d..e2f55cbc0d 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -52,7 +52,7 @@ Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute - A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the host-only `forgeWorktree` projection: requests and withdrawals apply until the first `turn/start` or creation, and the first `created` record of an open request applies until the first `step/start`. `requestWorktree` refuses with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; `cancelWorktree` refuses only with the first and needs no `worktrees` service; both forget a failed attempt. When a message enters the inbox of a Session whose request is open, an `agent/inbox/inserted` listener starts maintenance of the Session's Agent before the delivery wakes it, so the Agent latches the wake and starts the message's turn once that hold ends. The hold makes one attempt with a new branch name, publishing the running stage in the panel state with `held` set and `waiting` as `creation`, and records `created` with the source repository root. After a failed attempt, whose branch, base, failed step, and reason stay in the panel state as `failure`, or after `stopCreation`, the hold waits: `retryWorktree` makes another attempt, refused with `forgePanel/not-waiting` while none waits or one runs, and withdrawing the request lets the message go on in the current checkout. Each message that enters while the Agent is idle and the Session works in its created worktree starts a hold that checks whether the worktree is missing; while it is, the hold waits with `waiting` as `restoration`, and `restoreWorktree` or `continueInProject` runs inside it and then lets the message go on; a check that cannot answer lets the message go at once. The Agent's cancellation and the Remote's stop end a hold, and a message that enters while the Agent runs a turn or other maintenance gets none. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message no hold took; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` run as maintenance of their own while no message waits; `restoreWorktree` publishes its running step as `restoration`, and clears `missing` in the same state that ends it. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`. `markReady` and `merge` act only on a visible binding of the Session that a provider serves, else they refuse with `forgePanel/not-bound`; `merge` passes the head commit the card showed, and a moved head refuses with `forgePanel/stale-head`, while any other refusal is `forgePanel/action-failed` with the host's reason. -Restoration records `restoring` before mutation and `restored` only after success. Projection version 3 retains incomplete restoration across reloads. Directory existence alone cannot release a waiting message: the checkout root and shared Git directory must match the recorded worktree. +Restoration records `restoring` before mutation and `restored` only after success. Projection version 3 retains incomplete restoration across reloads. Directory existence alone cannot release a waiting message: the checkout root and shared Git directory must match the recorded worktree. Plugin unload cancels and joins all creation, restoration, and waiting tasks before releasing panel readers and the projection. Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 8d634e5152..9ac8a38107 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -52,7 +52,7 @@ kind: "package-reference" worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;`cancelWorktree` 只以第一种拒绝,也不需要 `worktrees` 服务;两者都会忘掉失败的尝试。消息进入有未完成请求的 Session 的收件箱时,一个 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Session 所属 Agent 的维护任务,因此 Agent 会暂存唤醒,等这次暂留结束后才开始这条消息的这一轮。暂留使用新分支名进行一次尝试,在面板状态中发布正在进行的阶段,设置 `held`,`waiting` 为 `creation`,并记录带源仓库根目录的 `created`。尝试失败(其分支、基准、失败步骤与原因以 `failure` 留在面板状态中)或经 `stopCreation` 停止后,暂留继续等待:`retryWorktree` 再做一次尝试,没有消息等待或已有尝试进行时以 `forgePanel/not-waiting` 拒绝;撤回请求则让消息在当前检出中继续。Agent 空闲且 Session 在所创建 worktree 中工作时,进入的每条消息都会启动一次暂留,检查该 worktree 是否缺失;缺失期间暂留以 `waiting` 为 `restoration` 等待,`restoreWorktree` 或 `continueInProject` 在暂留内运行,之后放行消息;检查无法给出结果时立即放行。Agent 的取消与 Remote 的停止都会结束暂留;Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖没有被暂留的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。没有消息等待时,`restoreWorktree` 与 `continueInProject` 以各自的维护任务运行;`restoreWorktree` 以 `restoration` 发布正在进行的步骤,并在结束恢复的同一状态中清除 `missing`。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。`markReady` 与 `merge` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;`merge` 带上卡片显示的 head 提交,head 已变化时以 `forgePanel/stale-head` 拒绝,其他拒绝都是带主机原因的 `forgePanel/action-failed`。 -恢复在修改前记录 `restoring`,仅在成功后记录 `restored`。投影版本 3 在重载后保留未完成的恢复。目录存在本身不能放行等待的消息:检出根目录与公共 Git 目录必须匹配记录的工作树。 +恢复在修改前记录 `restoring`,仅在成功后记录 `restored`。投影版本 3 在重载后保留未完成的恢复。目录存在本身不能放行等待的消息:检出根目录与公共 Git 目录必须匹配记录的工作树。插件卸载会取消并等待所有创建、恢复与消息等待任务,然后释放面板读取器和投影。 每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。 diff --git a/packages/experimental/client-ui-forge/src/index.ts b/packages/experimental/client-ui-forge/src/index.ts index ca4e4d87c9..9df46e3bf4 100644 --- a/packages/experimental/client-ui-forge/src/index.ts +++ b/packages/experimental/client-ui-forge/src/index.ts @@ -77,6 +77,7 @@ export class ForgePanelController extends TypertRemoteService { private worktreeStates!: ProjectionHandle private creations!: WorktreeCreations private readonly patches: PatchCache + private readonly stopPanels: () => Promise constructor(ctx: Context, config: Config) { super(ctx, 'forgePanel') @@ -95,7 +96,7 @@ export class ForgePanelController extends TypertRemoteService { this.home = ctx this.config = resolved this.patches = new PatchCache(resolved.maxCachedDiffBytes) - ctx.effect(() => async () => { + this.stopPanels = ctx.effect(() => async () => { const panels = [...this.panels.values()] this.panels.clear() await Promise.allSettled(panels.map(panel => panel.dispose())) @@ -104,19 +105,35 @@ export class ForgePanelController extends TypertRemoteService { protected async [Service.init](): Promise { const ctx = this.home - this.worktreeStates = await ctx.sessionProjections.register(forgeWorktreeProjection) + const stateOwner = ctx.plugin({ + name: 'forge-panel-state', + inject: ['sessionProjections'], + apply: async (scope: Context) => { + this.worktreeStates = await scope.sessionProjections.register(forgeWorktreeProjection) + }, + }) + await stateOwner // Inbox insertions, step proposals, and Session events read the worktree state, so they start once its reader is ready. this.creations = new WorktreeCreations(ctx, this.worktreeStates, this.config.worktreePrefix, (session) => { this.panels.get(session)?.updateWorktree() }) - ctx.on('agent/inbox/inserted', ({ agent }) => { this.holdTurn(agent) }) - ctx.on('session/event', (session: Session, event: SessionEvent) => { + const inbox = ctx.on('agent/inbox/inserted', ({ agent }) => { this.holdTurn(agent) }) + const events = ctx.on('session/event', (session: Session, event: SessionEvent) => { this.panels.get(session)?.observe(event) }) - ctx.on('internal/service', (name: string) => { + const services = ctx.on('internal/service', (name: string) => { if (name !== 'worktrees') return for (const panel of this.panels.values()) panel.updateWorktree() }) + const { creations, stopPanels } = this + ctx.effect(function* () { + yield stateOwner.dispose + yield inbox + yield events + yield services + yield stopPanels + yield creations.dispose + }, 'forge-panel: drain before releasing state') } /** @@ -417,8 +434,9 @@ export class ForgePanelController extends TypertRemoteService { await this.maintain(session, async (agent, work) => { const operation = AbortSignal.any([signal, work]) try { - if (await missingWorktree(this.home, this.config, session, this.worktreeState(session), created.path, operation) === null) return - await this.creations.restore(agent, worktrees, created, operation, () => { this.panels.get(session)?.restored() }) + await this.creations.restore(agent, worktrees, created, operation, + () => { this.panels.get(session)?.restored() }, + async check => await missingWorktree(this.home, this.config, session, this.worktreeState(session), created.path, check) === null) } catch (error) { operation.throwIfAborted() throw new RemoteError('forgePanel/restore-failed', messageOf(error), {}, { cause: error }) diff --git a/packages/experimental/client-ui-forge/src/worktree.ts b/packages/experimental/client-ui-forge/src/worktree.ts index 758ce8e184..dd8192b29e 100644 --- a/packages/experimental/client-ui-forge/src/worktree.ts +++ b/packages/experimental/client-ui-forge/src/worktree.ts @@ -187,6 +187,9 @@ export class WorktreeCreations { private readonly failures = new Map() private readonly holds = new Map() private readonly lifetime = new AbortController() + private readonly pending = new Set>() + /** Stop admission, cancel operations, and wait for their final callbacks. */ + readonly dispose: () => Promise /** * @param ctx - context that optionally provides `worktrees`. @@ -202,7 +205,10 @@ export class WorktreeCreations { private readonly changed: (session: Session) => void, ) { ctx.on('agent/pre-step', async ({ agent, step, signal }, next): Promise => await this.preStep(agent, step, signal, next)) - ctx.effect(() => () => { this.lifetime.abort(new Error('The forge panel stopped')) }, 'forge-panel: release waiting messages') + this.dispose = ctx.effect(() => async () => { + this.lifetime.abort(new Error('The forge panel stopped')) + await Promise.allSettled(this.pending) + }, 'forge-panel: stop worktree operations') } /** @@ -329,6 +335,7 @@ export class WorktreeCreations { * @param created - the worktree the Session created. * @param signal - cancellation of the restoration. * @param restored - runs once the checkout is back, before the restoration ends, so both changes publish together. + * @param available - checks whether the recorded checkout is already usable, within this operation's lifetime. * @throws the worktree service's error when the restoration fails; the cancellation reason once `signal` aborts. */ async restore( @@ -337,6 +344,21 @@ export class WorktreeCreations { created: ForgeSessionWorktree, signal: AbortSignal, restored: () => void, + available: (signal: AbortSignal) => Promise, + ): Promise { + await this.run(signal, async (operation) => { + const usable = await available(operation) + operation.throwIfAborted() + if (!usable) await this.restoreCheckout(agent, worktrees, created, operation, restored) + }) + } + + private async restoreCheckout( + agent: Agent, + worktrees: Context['worktrees'], + created: ForgeSessionWorktree, + signal: AbortSignal, + restored: () => void, ): Promise { const session = agent.session appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' }) @@ -366,6 +388,10 @@ export class WorktreeCreations { * @throws {@link CreationFailure} when the attempt fails; the cancellation reason once `signal` aborts. */ async create(agent: Agent, from: string | null, signal: AbortSignal, held: boolean): Promise { + return await this.run(signal, operation => this.createCheckout(agent, from, operation, held)) + } + + private async createCheckout(agent: Agent, from: string | null, signal: AbortSignal, held: boolean): Promise { const worktrees = this.ctx.get('worktrees') if (worktrees === undefined) throw new Error('No worktree service is mounted') const session = agent.session @@ -385,6 +411,7 @@ export class WorktreeCreations { signal.throwIfAborted() throw new CreationFailure(status.branch, status.phase, messageOf(error), error) } + signal.throwIfAborted() const worktree: ForgeSessionWorktree = { path: created.path, branch: created.branch, from, baseCommit: created.baseCommit, repositoryRoot: created.repositoryRoot, } @@ -404,6 +431,7 @@ export class WorktreeCreations { * @param body - waits for the worktree; settles once the message may go on. */ private hold(agent: Agent, hold: H, body: (hold: H, signal: AbortSignal) => Promise): void { + if (this.lifetime.signal.aborted) return const session = agent.session if (this.holds.has(session)) return let task: Promise @@ -426,7 +454,22 @@ export class WorktreeCreations { return } this.holds.set(session, hold) - void task + void this.join(task) + } + + private async run(signal: AbortSignal, body: (signal: AbortSignal) => Promise): Promise { + const operation = AbortSignal.any([signal, this.lifetime.signal]) + operation.throwIfAborted() + return await this.join(body(operation)) + } + + private async join(task: Promise): Promise { + this.pending.add(task) + try { + return await task + } finally { + this.pending.delete(task) + } } /** diff --git a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts index e6d7973e66..8326483933 100644 --- a/packages/experimental/client-ui-forge/tests/controller.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/controller.host.spec.ts @@ -1674,7 +1674,51 @@ describe('worktree recovery and unload', () => { expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo) }) - + it.each(['creation', 'restoration', 'validation'] as const)('waits for cancelled %s before completing panel unload', async (kind) => { + const { repo } = await project() + const opened = await open(repo, {}, { worktrees: true }) + const created = kind !== 'creation' ? await startInWorktree(opened) : undefined + if (created !== undefined) await rm(created.path, { recursive: true, force: true }) + else await opened.ctx.forgePanel.requestWorktree(opened.session, null, signal) + const entered: PromiseWithResolvers = Promise.withResolvers() + const released: PromiseWithResolvers = Promise.withResolvers() + let cancellation: AbortSignal | undefined + const confine = opened.ctx.sandbox.confine.bind(opened.ctx.sandbox) + const inspection = vi.spyOn(opened.ctx.sandbox, 'confine').mockImplementation(async (argv, policy, signal) => { + if (kind === 'validation' && argv.includes('show-ref')) { + cancellation = signal; entered.resolve(); await released.promise + } + return confine(argv, policy, signal) + }) + const create = vi.spyOn(opened.ctx.worktrees, 'create').mockImplementation(async (_agent, _request, signal) => { + cancellation = signal; entered.resolve(); await released.promise + return { path: join(repo, '.agents/worktrees/pending'), branch: 'pending', repositoryRoot: repo, baseCommit: 'a'.repeat(40) } + }) + const restore = vi.spyOn(opened.ctx.worktrees, 'restore').mockImplementation(async (_agent, request, signal) => { + cancellation = signal; entered.resolve(); await released.promise + return { path: request.path, branchRecreated: false } + }) + const operation = rejectionOf(kind === 'creation' + ? proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')] }) + : opened.ctx.forgePanel.restoreWorktree(opened.session, signal)) + let disposed = false + let unloading: Promise | undefined + try { + await entered.promise + unloading = opened.panel.dispose().then(() => { disposed = true }) + await vi.waitFor(() => { expect(cancellation?.aborted).toBe(true) }) + expect(disposed).toBe(false) + released.resolve() + expect(await operation).toBeInstanceOf(Error) + await unloading + expect(opened.records.filter(record => typeof record === 'object' && record !== null && 'action' in record + && record.action === (kind === 'creation' ? 'created' : 'restored'))).toEqual([]) + } finally { + released.resolve() + await Promise.allSettled([operation, ...unloading === undefined ? [] : [unloading]]) + create.mockRestore(); restore.mockRestore(); inspection.mockRestore() + } + }) }) diff --git a/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts b/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts index f69636a6af..ab104ceff5 100644 --- a/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts +++ b/packages/experimental/client-ui-forge/tests/worktree.host.spec.ts @@ -517,3 +517,13 @@ describe('worktree creation at the first step proposal', () => { expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo) }) }) + + +it('does not admit a new message hold after worktree operations stop', async () => { + const opened = await creating(await project()) + request(opened, null) + await opened.creations.dispose() + opened.creations.holdCreation(opened.agent) + expect(opened.creations.waitingOf(opened.session)).toBeNull() + expect(opened.changes).toEqual([]) +}) From a6883f40380786855ee204b16517f13c51f98ed2 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 18:54:40 +0800 Subject: [PATCH 30/32] test: calibrate npm layout budget for the expanded release graph --- ...dual-release-install-layout-work-budget.i18n.yaml | 12 ++++++------ ...-09-28-dual-release-install-layout-work-budget.md | 8 +++++--- ...-28-dual-release-install-layout-work-budget.zh.md | 8 +++++--- scripts/verify-npm-install-layout.spec.ts | 10 +++++----- scripts/verify-npm-install-layout.ts | 10 ++++++---- 5 files changed, 27 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml index c8ae9617c4..7005f647dc 100644 --- a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml +++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.i18n.yaml @@ -9,14 +9,14 @@ en: 46916488210ba493 zh: 98107c3805a22b2c /agent-note-dual-release-install-layout-work-budget/decision: - en: c432b68c4ad1b3c1 - zh: a11e958fa68dc2f3 + en: b900469750b27f81 + zh: d5380d7751332e9c /agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs: - en: 36d3e3ef39cbba2f - zh: 2480acf6260817b3 + en: 463df1fae15ae2fc + zh: 733c9ddb4900e3fc /agent-note-dual-release-install-layout-work-budget/alternatives-considered: en: 29987b7634c84d28 zh: a1469e4b6aaa46e2 /agent-note-dual-release-install-layout-work-budget/consequences: - en: 8dffc0c3fa699980 - zh: 99ec25001e5aa716 + en: 4226e2216abe042d + zh: db8f308ef40f47e9 diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md index 24e2ae8fe7..43dd69c9df 100644 --- a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md +++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.md @@ -10,14 +10,16 @@ The `Dependency layout` lane packs two incompatible synthetic DSH releases into ## Decision -`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant. +`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 1_134_400`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant. -The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart. +The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (about 1,180 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart. This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it. ## What the resolution costs +Measured 2026-10-26 on an idle Apple M5 Pro with Node 24.17.0 and npm 11.13.0, using `buildDualDshRegistry`, `resolveNpmPackageLock`, and `assertDualDshInstallLayout` sequentially with fresh temporary consumers: the #5960 graph (315 packages per release, 2782 internal edges, 876,330 work units) resolved in 75.27 s; the #5961 graph (320 packages, 2836 edges, 907,520 units) resolved in 71.03 s, with a second verification at 97.73 s. All placements passed, shared one Cordis installation, and requested no archives. The measurements calibrate graph admission and do not establish a runtime improvement. The current budget is 1.25 times its 907,520 units; the earlier, slower per-unit timing remains the conservative hang-guard calibration. + Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`: | Phase | Cost | @@ -47,4 +49,4 @@ Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time, ## Consequences -The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires. +The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to about 1,180 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires. diff --git a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md index 03380bca7a..07c58d75e1 100644 --- a/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md +++ b/.agents/notes/implemented/process/2026-09-28-dual-release-install-layout-work-budget.zh.md @@ -10,14 +10,16 @@ Status: implemented ## Decision -`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。 +`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 1_134_400` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。 -npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。 +npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(约 1,180 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。 这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。 ## What the resolution costs +2026-10-26 在空闲 Apple M5 Pro 上使用 Node 24.17.0 与 npm 11.13.0,通过 `buildDualDshRegistry`、`resolveNpmPackageLock` 和 `assertDualDshInstallLayout`,对独立的新建临时消费者目录依次测量:#5960 的依赖图(每个发布 315 个包、2782 条内部边、876,330 个工作量单位)用 75.27 秒解析;#5961 的依赖图(320 个包、2836 条边、907,520 个工作量单位)用 71.03 秒解析,第二次验证耗时 97.73 秒。所有布局校验均通过,共享一个 Cordis 安装,未请求包归档。这些测量用于校准依赖图的准入规模,不代表运行时性能提升。当前预算为其 907,520 个工作量单位的 1.25 倍;挂起保护仍采用此前较慢的单位工作量耗时作为保守校准。 + 2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得: | 阶段 | 开销 | @@ -47,4 +49,4 @@ npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION ## Consequences -lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。 +lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多约 1,180 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。 diff --git a/scripts/verify-npm-install-layout.spec.ts b/scripts/verify-npm-install-layout.spec.ts index 032e3f9ef7..d8e4319bba 100644 --- a/scripts/verify-npm-install-layout.spec.ts +++ b/scripts/verify-npm-install-layout.spec.ts @@ -118,12 +118,12 @@ describe('npm install layout verifier', () => { describe('resolution work budget', () => { it('accepts the measured dual-release graph', () => { - expect(assertResolutionWorkBudget({ dshPackagesPerVersion: 277, checkedDshEdges: 2524 })) - .toBe(699_148) + expect(assertResolutionWorkBudget({ dshPackagesPerVersion: 320, checkedDshEdges: 2836 })) + .toBe(907_520) }) it('accepts a graph exactly at the budget and rejects one internal edge above it', () => { - const packages = 350 + const packages = 320 const edgesAtBudget = Math.floor(MAX_RESOLUTION_WORK_UNITS / packages) expect(packages * edgesAtBudget).toBe(MAX_RESOLUTION_WORK_UNITS) @@ -136,8 +136,8 @@ describe('resolution work budget', () => { }) it('accepts the headroom and rejects a runaway graph with its own measured counts', () => { - expect(assertResolutionWorkBudget({ dshPackagesPerVersion: 300, checkedDshEdges: 2800 })) - .toBe(840_000) + expect(assertResolutionWorkBudget({ dshPackagesPerVersion: 350, checkedDshEdges: 3200 })) + .toBe(1_120_000) expect(() => assertResolutionWorkBudget({ dshPackagesPerVersion: 400, checkedDshEdges: 3000 })) .toThrow(`400 package(s) per release x 3000 internal edge(s) = 1200000 unit(s), budget ${String(MAX_RESOLUTION_WORK_UNITS)} unit(s)`) }) diff --git a/scripts/verify-npm-install-layout.ts b/scripts/verify-npm-install-layout.ts index fd3d7a7ae8..243e5e7dc3 100644 --- a/scripts/verify-npm-install-layout.ts +++ b/scripts/verify-npm-install-layout.ts @@ -24,17 +24,19 @@ const DEPENDENCY_FIELDS = ['dependencies', 'optionalDependencies', 'peerDependen * edges of its peer target (`canPlacePeers` calls `checkCanPlaceNoCurrent`, * which walks `depValid` over that target's edges), so resolution time grows * with this product and not with the package count alone. Measured on an idle - * M-series host: 277 package(s) x 2524 internal edge(s) = 699,148 unit(s) cost - * 180.7 s of npm time out of 185.1 s end to end. The budget adds 25% headroom + * M5 Pro with Node 24.17.0 and npm 11.13.0: 320 package(s) x 2836 internal + * edge(s) = 907,520 unit(s) cost 71.03–97.73 s of npm time; the 315-package, + * 2782-edge graph took 75.27 s on the same host. The budget adds 25% headroom * for ordinary product growth, so a graph that grows here fails with its own * measured counts instead of failing on whichever runner happens to be slow. * Raising it requires re-measuring the resolution and recording the new counts. */ -export const MAX_RESOLUTION_WORK_UNITS = 880_950 +export const MAX_RESOLUTION_WORK_UNITS = 1_134_400 /** * Measured npm seconds per resolution work unit on the reference host: 180.66 s - * for the 699,148 units of the graph the budget was derived from. + * for 699,148 units. This conservative hang-guard calibration also covers the + * current 907,520-unit graph measured at 71.03–97.73 s on an M5 Pro. */ const SECONDS_PER_WORK_UNIT = 2.6e-4 From 2fa853870f0ec52b97c019145af4d6cd3dc63fcf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 19:34:16 +0800 Subject: [PATCH 31/32] fix(forge-panel): register all shipped locale dictionaries --- .../client-ui-forge/README.i18n.yaml | 4 +- .../experimental/client-ui-forge/README.md | 2 + .../experimental/client-ui-forge/README.zh.md | 2 + .../client-ui-forge/src/client/locales.ts | 1200 +++++++++++++++++ .../client-ui-forge/src/client/mount.ts | 4 +- .../tests/mount.client.spec.ts | 2 + 6 files changed, 1210 insertions(+), 4 deletions(-) diff --git a/packages/experimental/client-ui-forge/README.i18n.yaml b/packages/experimental/client-ui-forge/README.i18n.yaml index 5c5bedb185..4ee0008670 100644 --- a/packages/experimental/client-ui-forge/README.i18n.yaml +++ b/packages/experimental/client-ui-forge/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: 2f8a621b991260a1 /deepseek-ai-dsh-experimental-client-ui-forge/use-this-package: - en: 10cb328b8b92f077 - zh: 473121a22ce76dfb + en: 675b24649622d158 + zh: 3c27fc1d7e20731f /deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation: en: 0cb26601d077a723 zh: af04ea90c3d3b704 diff --git a/packages/experimental/client-ui-forge/README.md b/packages/experimental/client-ui-forge/README.md index e2f55cbc0d..d0db60f333 100644 --- a/packages/experimental/client-ui-forge/README.md +++ b/packages/experimental/client-ui-forge/README.md @@ -27,6 +27,8 @@ This optional two-face package shows the change requests a Session created or pu Enable the [forge bundle](../forge-bundle/README.md) on the Plugins page; it composes this package with the forge registry, the GitHub provider, the Session bindings, and its own [worktree service](../worktree/README.md). +Panel text follows the application language: Chinese, English, Japanese, Spanish, French, German, Russian, or Portuguese. + - **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh--<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists. - **Creation card** — while the worktree is created, a card above the composer shows the new branch and its two steps, creating the branch and checking out files; More details adds the branch and base. Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting, and the card shows the reason of a failure: Retry tries again with a new branch name, and Continue without a worktree withdraws the request so the message goes on in the current checkout. - **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph. diff --git a/packages/experimental/client-ui-forge/README.zh.md b/packages/experimental/client-ui-forge/README.zh.md index 9ac8a38107..957bc62c47 100644 --- a/packages/experimental/client-ui-forge/README.zh.md +++ b/packages/experimental/client-ui-forge/README.zh.md @@ -27,6 +27,8 @@ kind: "package-reference" 在插件页启用 [forge 套件](../forge-bundle/README.zh.md);它把本包与 forge 注册表、GitHub 提供方、Session 绑定以及它自己的 [worktree 服务](../worktree/README.zh.md)组合在一起。 +面板文案跟随应用语言,支持中文、英文、日语、西班牙语、法语、德语、俄语和葡萄牙语。 + - **工作树选项**——在新会话页面上,“工作树”位于工作区与模式选择器那一行的右端。勾选后显示基准分支,默认为当前分支;菜单列出最近的本地分支。不在 Git 检出中、Session 为只读,或检出及其 Git 目录不在 Session 的可写根内(例如项目本身就是链接 worktree)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh--<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。 - **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。 - **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。 diff --git a/packages/experimental/client-ui-forge/src/client/locales.ts b/packages/experimental/client-ui-forge/src/client/locales.ts index f10c40b6d0..b890dac394 100644 --- a/packages/experimental/client-ui-forge/src/client/locales.ts +++ b/packages/experimental/client-ui-forge/src/client/locales.ts @@ -412,3 +412,1203 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { forgePanel: ForgePanelKey } } + +/** Japanese copy with the same keys. */ +export const ja: Record = { + 'tab.title': '変更リクエスト', + 'header.label': 'このセッションの変更リクエスト', + 'header.attention': 'このセッションの変更リクエストに対応が必要です', + 'checkout.worktree': 'ワークツリー', + 'checkout.worktreeFrom': 'ワークツリー · 基点 {base}', + 'checkout.project': 'プロジェクトディレクトリ', + 'checkout.detached': 'ブランチ上にありません', + 'checkout.restore': '復元', + 'checkout.copyWorktreePath': 'ワークツリーのパスをコピー', + 'checkout.copyProjectPath': 'プロジェクトのパスをコピー', + 'checkout.none': 'このディレクトリは Git リポジトリではありません', + 'checkout.failed': 'Git 情報を読み取れませんでした', + 'changes.title': 'このセッションの変更リクエスト · {count}', + 'changes.empty': 'このセッションにはまだ変更リクエストがありません', + 'changes.emptyHint': 'このセッションで作成、表示、編集、チェックアウトした変更リクエストや、現在のブランチまたはプッシュしたブランチで開いている変更リクエストがここに表示されます', + 'changes.local.title': '変更', + 'changes.local.refs': '{head} → {base}', + 'changes.local.files': '{count} ファイル', + 'changes.local.viewAria': 'このセッションの変更を表示:{count} ファイル、{added} 行追加、{deleted} 行削除', + 'changes.local.create': '変更リクエストを作成', + 'changes.local.prompt': 'このセッションの変更について変更リクエストを作成してください', + 'signin.title': '変更リクエストの状態を表示するには {host} にログインしてください', + 'signin.copy': 'ログインコマンドをコピー', + 'signin.unavailable': '{host} の変更リクエストを読み取れませんでした:{message}', + 'refresh': '状態を更新', + 'retry': '再試行', + 'stream.failed': '変更リクエストパネルを読み込めませんでした', + 'change.number': '#{number}', + 'change.stateAria': '変更リクエストの状態:{state}', + 'change.state.open': 'オープン', + 'change.state.blocked': '対応が必要', + 'change.state.draft': '下書き', + 'change.state.merged': 'マージ済み', + 'change.state.closed': 'クローズ済み', + 'change.state.unknown': '不明', + 'change.expand': '{name} を展開', + 'change.collapse': '{name} を折りたたむ', + 'change.ci.none': 'チェックなし', + 'change.ci.running': 'チェック実行中', + 'change.ci.passed': 'チェック成功', + 'change.ci.failed': '{count} 件のチェックが失敗', + 'change.ci.failedUnlisted': 'チェック失敗', + 'change.checks.failed': '{count} 件失敗', + 'change.checks.running': '{count} 件実行中', + 'change.checks.passed': '{count} 件成功', + 'change.checks.other': 'その他 {count} 件', + 'change.checks.truncated': '最初の {count} 件のみ表示', + 'change.check.running': '実行中', + 'change.check.queued': 'キュー待ち', + 'change.flag.changesRequested': '変更が要求されています', + 'change.flag.conflicting': '競合あり', + 'change.flag.draft': '下書き', + 'change.failure.unavailable': '変更リクエストの状態を読み取れませんでした', + 'change.failure.notFound': '変更リクエストが存在しないか、アクセス権がありません', + 'change.failure.unauthenticated': '状態を読むにはログインしてください', + 'change.unserved': 'このコードホストの変更リクエストの状態はまだサポートされていません', + 'change.view': 'ブラウザーで開く', + 'change.copy': '変更リクエストのリンクをコピー', + 'change.viewChangesAria': '{name} の変更を表示:{added} 行追加、{deleted} 行削除', + 'change.remove': 'このセッションから削除', + 'change.removeAria': 'このセッションから {name} を削除', + 'change.row.checks': 'チェック', + 'change.row.review': 'レビュー', + 'change.row.merge': 'マージ', + 'change.row.changes': '変更', + 'change.review.approved': '承認済み', + 'change.review.changesRequested': '変更が要求されています', + 'change.review.required': 'レビュー待ち', + 'change.review.none': 'レビュー未依頼', + 'change.review.threads': '{review} · 未解決 {count} 件', + 'change.merge.mergeable': '競合なし', + 'change.merge.conflicting': '{base} と競合', + 'change.merge.unknown': '競合を確認中', + 'change.mergedTo': '{base} にマージ済み', + 'change.markReady': 'レビュー可能にする', + 'change.merge': 'マージ', + 'change.mergeTitle': '{name} を {base} にマージ', + 'change.mergeMethod.squash': 'スカッシュしてマージ', + 'change.mergeMethod.merge': 'マージコミットを作成', + 'change.mergeMethod.rebase': 'リベースしてマージ', + 'change.mergeNote.squash': 'すべてのコミットを1つにまとめる', + 'change.mergeNote.merge': 'すべてのコミットを保持し、マージコミットを追加', + 'change.mergeNote.rebase': '各コミットを {base} 上にリベース', + 'change.mergeConfirm': 'マージを確認', + 'change.mergeChanged': '新しいコミットがあります。確認してからマージメニューを開き直してください', + 'diff.tab': '変更', + 'diff.tabNumbered': '#{number} の変更', + 'diff.scope.branch': 'ブランチ', + 'diff.scope.uncommitted': '未コミット', + 'diff.scope.unstaged': '未ステージ', + 'diff.scope.staged': 'ステージ済み', + 'diff.scopeNote.branch': '{base} と比較、未コミットの変更を含む', + 'diff.scopeNote.uncommitted': 'HEAD と比較、未追跡ファイルを含む', + 'diff.scopeNote.unstaged': 'インデックスと比較、未追跡ファイルを含む', + 'diff.scopeNote.staged': 'インデックスと HEAD を比較', + 'diff.allFiles': '全 {count} ファイル', + 'diff.refresh': '変更を更新', + 'diff.expandAll': 'すべてのファイルを展開', + 'diff.collapseAll': 'すべてのファイルを折りたたむ', + 'diff.split': '左右に並べて表示', + 'diff.unified': '1列で表示', + 'diff.splitAria': '左右表示', + 'diff.wrap': '長い行を折り返す', + 'diff.nowrap': '長い行を折り返さない', + 'diff.wrapAria': '行を折り返す', + 'diff.unchanged': '未変更 {count} 行', + 'diff.unchangedRest': '残りの未変更行', + 'diff.newFile': '新規ファイル', + 'diff.deletedFile': '削除済み', + 'diff.renamed': '{from} から名前変更', + 'diff.renamedPath': '{from} → {path}', + 'diff.badgeUncommitted': '未コミット', + 'diff.binary': 'バイナリファイルのため変更を表示できません', + 'diff.binaryLabel': 'バイナリ', + 'diff.largeCollapsed': '大きなファイル:変更された {count} 行を折りたたみました', + 'diff.fileTooLarge': 'ファイルが大きすぎて表示できません', + 'diff.error': '変更を読み取れませんでした', + 'diff.tooLarge': '変更が大きすぎるため、ここには表示できません', + 'diff.viewInBrowser': 'ブラウザーで表示', + 'diff.noForkPoint': 'このブランチの分岐点が見つかりません。別の範囲を選んでください', + 'diff.empty': '変更なし', + 'diff.omitted': 'ほかに {count} 個の未追跡ファイルがあります', + 'diff.stale': 'ローカルブランチに未プッシュのコミットが {count} 件あります。ここには変更リクエストの変更を表示しています', + 'diff.moreActions': '{name} のその他の操作', + 'diff.copyPath': 'パスをコピー', + 'diff.openFileInBrowser': 'ブラウザーでファイルを表示', + 'diff.openFileInSidebar': 'サイドバーでファイル全体を開く', + 'time.now': 'たった今', + 'time.minutes': '{n} 分前', + 'time.hours': '{n} 時間前', + 'time.days': '{n} 日前', + 'time.months': '{n} か月前', + 'time.years': '{n} 年前', + 'time.finished': '{state} · {time}', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分 {seconds}秒', + 'duration.hours': '{hours}時間 {minutes}分', + 'worktree.label': 'ワークツリー', + 'worktree.hint': 'このセッションを専用のワークツリーで実行します。送信時に選択したブランチから新しいブランチを作成するため、変更やコミットはプロジェクトディレクトリに影響しません', + 'worktree.blocked.not-git': 'このワークスペースは Git リポジトリではないため、ワークツリーを使用できません', + 'worktree.blocked.outside-writable-root': 'このワークスペースの Git データが書き込み可能なディレクトリ外にあります。ワークスペース自体がワークツリーである場合などは、まだワークツリーを使用できません', + 'worktree.blocked.read-only': '読み取り専用のセッションではワークツリーを使用できません', + 'worktree.base': '基点 {branch}', + 'worktree.baseHead': '現在の HEAD を基点にする', + 'worktree.baseMenu': '基準ブランチ', + 'worktree.currentBranch': '現在', + 'creation.title': 'ワークツリーを作成中', + 'creation.failed': 'ワークツリーを作成できませんでした', + 'creation.prepare': 'ブランチを作成', + 'creation.checkout': 'ファイルをチェックアウト', + 'creation.details': '詳細', + 'creation.branch': 'ブランチ', + 'creation.base': '基点', + 'creation.head': '現在の HEAD', + 'creation.error': '理由', + 'creation.skip': 'ワークツリーを使わずに続行', + 'creation.cancel': 'キャンセル', + 'creation.stopped': 'ワークツリーの作成を停止しました', + 'creation.retry': '再試行', + 'creation.waiting': 'ワークツリーの作成後にメッセージを送信します', + 'missing.title': 'ワークツリーを利用できません', + 'missing.unknown': 'ワークツリーの復元が未完了のため、ブランチ {branch} を確認できません。復元エラーを解消して再試行するか、プロジェクトで続行してください', + 'missing.kept': 'ブランチ {branch} のワークツリーを元の場所に復元できます。削除前の未コミットの変更は復元できません', + 'missing.recreated': 'ブランチ {branch} も削除されています。復元時はワークツリー作成時のコミットから再作成します。それ以降のコミットや未コミットの変更は復元されません', + 'missing.restore': 'ワークツリーを復元', + 'missing.leave': 'プロジェクトで続行', + 'missing.waiting': 'ワークツリーを復元するかプロジェクトで続行すると、メッセージが送信されます', + 'restoration.title': 'ワークツリーを復元中', + 'restoration.prepare': 'ワークツリーを準備', + 'branch.worktree': 'ワークツリー(基点 {base}):{path}', + 'branch.linked': 'ワークツリー:{path}', + 'branch.project': 'プロジェクトディレクトリ:{path}', + 'branch.missing': 'ワークツリー利用不可:{path}', + 'toast.removed': 'このセッションから {name} を削除しました', + 'toast.undo': '元に戻す', + 'toast.undoFailed': '元に戻せませんでした', + 'toast.removeFailed': '削除できませんでした', + 'toast.refreshFailed': '更新できませんでした', + 'toast.copied': 'リンクをコピーしました', + 'toast.copyFailed': 'コピーに失敗しました', + 'toast.branchesFailed': 'ブランチを読み取れませんでした', + 'toast.worktreeFailed': 'ワークツリーの設定を変更できませんでした', + 'toast.worktreeStarted': 'セッションが開始済みのため、ワークツリーに切り替えられません', + 'toast.restored': 'ワークツリーを復元しました', + 'toast.restoreFailed': 'ワークツリーを復元できませんでした:{message}', + 'toast.leaveFailed': 'プロジェクトディレクトリに切り替えられませんでした。再試行してください', + 'toast.markedReady': '{name} をレビュー可能にしました', + 'toast.markReadyFailed': 'レビュー可能にできませんでした:{message}', + 'toast.merged': '{name} をマージしました', + 'toast.mergeFailed': 'マージできませんでした:{message}', + 'toast.mergeStale': '{name} に新しいコミットがあります。確認してからマージしてください', + 'toast.pathCopied': 'パスをコピーしました', + 'toast.expandFailed': '未変更の行を表示できませんでした:{message}', + 'toast.expandStale': '変更の読み取り後にファイルが更新されています。更新してから展開してください', + 'toast.prompted': '送信しました:{text}', + 'toast.promptFailed': 'メッセージを送信できませんでした', +} + +/** Spanish copy with the same keys. */ +export const es: Record = { + 'tab.title': 'Solicitudes de cambio', + 'header.label': 'Solicitudes de cambio de esta sesión', + 'header.attention': 'Las solicitudes de cambio de esta sesión requieren atención', + 'checkout.worktree': 'Árbol de trabajo', + 'checkout.worktreeFrom': 'Árbol de trabajo · desde {base}', + 'checkout.project': 'Directorio del proyecto', + 'checkout.detached': 'Sin rama activa', + 'checkout.restore': 'Restaurar', + 'checkout.copyWorktreePath': 'Copiar ruta del árbol de trabajo', + 'checkout.copyProjectPath': 'Copiar ruta del proyecto', + 'checkout.none': 'Este directorio no es un repositorio Git', + 'checkout.failed': 'No se pudo leer la información de Git', + 'changes.title': 'Solicitudes de cambio de esta sesión · {count}', + 'changes.empty': 'Esta sesión aún no tiene solicitudes de cambio', + 'changes.emptyHint': 'Aquí aparecen las solicitudes vinculadas a esta sesión: creadas, consultadas, editadas o extraídas, o abiertas en sus ramas actuales o enviadas', + 'changes.local.title': 'Cambios', + 'changes.local.refs': '{head} → {base}', + 'changes.local.files': '{count} archivos', + 'changes.local.viewAria': 'Ver cambios de esta sesión: {count} archivos, {added} líneas añadidas y {deleted} eliminadas', + 'changes.local.create': 'Crear solicitud de cambio', + 'changes.local.prompt': 'Crea una solicitud de cambio con los cambios de esta sesión', + 'signin.title': 'Inicia sesión en {host} para ver el estado de las solicitudes', + 'signin.copy': 'Copiar comando de inicio de sesión', + 'signin.unavailable': 'No se pudieron leer las solicitudes de {host}: {message}', + 'refresh': 'Actualizar estado', + 'retry': 'Reintentar', + 'stream.failed': 'No se pudo cargar el panel de solicitudes de cambio', + 'change.number': '#{number}', + 'change.stateAria': 'Estado de la solicitud: {state}', + 'change.state.open': 'Abierta', + 'change.state.blocked': 'Requiere atención', + 'change.state.draft': 'Borrador', + 'change.state.merged': 'Fusionada', + 'change.state.closed': 'Cerrada', + 'change.state.unknown': 'Desconocida', + 'change.expand': 'Expandir {name}', + 'change.collapse': 'Contraer {name}', + 'change.ci.none': 'Sin comprobaciones', + 'change.ci.running': 'Comprobaciones en curso', + 'change.ci.passed': 'Comprobaciones superadas', + 'change.ci.failed': '{count} comprobaciones fallidas', + 'change.ci.failedUnlisted': 'Comprobaciones fallidas', + 'change.checks.failed': '{count} fallidas', + 'change.checks.running': '{count} en curso', + 'change.checks.passed': '{count} superadas', + 'change.checks.other': '{count} más', + 'change.checks.truncated': 'Solo se muestran las primeras {count}', + 'change.check.running': 'En curso', + 'change.check.queued': 'En cola', + 'change.flag.changesRequested': 'Cambios solicitados', + 'change.flag.conflicting': 'Conflictos', + 'change.flag.draft': 'Borrador', + 'change.failure.unavailable': 'No se pudo leer el estado de la solicitud', + 'change.failure.notFound': 'La solicitud no existe o no tienes acceso', + 'change.failure.unauthenticated': 'Inicia sesión para ver su estado', + 'change.unserved': 'Este servicio de código aún no permite consultar el estado de las solicitudes', + 'change.view': 'Abrir en el navegador', + 'change.copy': 'Copiar enlace de la solicitud', + 'change.viewChangesAria': 'Ver cambios de {name}: {added} líneas añadidas y {deleted} eliminadas', + 'change.remove': 'Quitar de esta sesión', + 'change.removeAria': 'Quitar {name} de esta sesión', + 'change.row.checks': 'Comprobaciones', + 'change.row.review': 'Revisión', + 'change.row.merge': 'Fusionar', + 'change.row.changes': 'Cambios', + 'change.review.approved': 'Aprobada', + 'change.review.changesRequested': 'Cambios solicitados', + 'change.review.required': 'Revisión requerida', + 'change.review.none': 'Sin revisión solicitada', + 'change.review.threads': '{review} · {count} sin resolver', + 'change.merge.mergeable': 'Sin conflictos', + 'change.merge.conflicting': 'Conflictos con {base}', + 'change.merge.unknown': 'Buscando conflictos', + 'change.mergedTo': 'Fusionada en {base}', + 'change.markReady': 'Lista para revisión', + 'change.merge': 'Fusionar', + 'change.mergeTitle': 'Fusionar {name} en {base}', + 'change.mergeMethod.squash': 'Fusionar con squash', + 'change.mergeMethod.merge': 'Crear un commit de fusión', + 'change.mergeMethod.rebase': 'Rebasar y fusionar', + 'change.mergeNote.squash': 'Combinar todos los commits en uno', + 'change.mergeNote.merge': 'Conservar todos los commits y añadir uno de fusión', + 'change.mergeNote.rebase': 'Aplicar cada commit sobre {base}', + 'change.mergeConfirm': 'Confirmar fusión', + 'change.mergeChanged': 'Hay nuevos commits. Revísalos antes de volver a abrir el menú de fusión', + 'diff.tab': 'Cambios', + 'diff.tabNumbered': 'Cambios en #{number}', + 'diff.scope.branch': 'Rama', + 'diff.scope.uncommitted': 'Sin confirmar', + 'diff.scope.unstaged': 'Sin preparar', + 'diff.scope.staged': 'Preparado', + 'diff.scopeNote.branch': 'Comparado con {base}, incluidos los cambios sin confirmar', + 'diff.scopeNote.uncommitted': 'Comparado con HEAD, incluidos los archivos sin seguimiento', + 'diff.scopeNote.unstaged': 'Comparado con el índice, incluidos los archivos sin seguimiento', + 'diff.scopeNote.staged': 'Índice comparado con HEAD', + 'diff.allFiles': 'Los {count} archivos', + 'diff.refresh': 'Actualizar cambios', + 'diff.expandAll': 'Expandir todos los archivos', + 'diff.collapseAll': 'Contraer todos los archivos', + 'diff.split': 'Mostrar en paralelo', + 'diff.unified': 'Mostrar en una columna', + 'diff.splitAria': 'Vista en paralelo', + 'diff.wrap': 'Ajustar líneas largas', + 'diff.nowrap': 'No ajustar líneas largas', + 'diff.wrapAria': 'Ajustar líneas', + 'diff.unchanged': '{count} líneas sin cambios', + 'diff.unchangedRest': 'Líneas restantes sin cambios', + 'diff.newFile': 'Archivo nuevo', + 'diff.deletedFile': 'Eliminado', + 'diff.renamed': 'Renombrado desde {from}', + 'diff.renamedPath': '{from} → {path}', + 'diff.badgeUncommitted': 'Sin confirmar', + 'diff.binary': 'Archivo binario; no se pueden mostrar sus cambios', + 'diff.binaryLabel': 'Binario', + 'diff.largeCollapsed': 'Archivo grande: {count} líneas modificadas contraídas', + 'diff.fileTooLarge': 'Este archivo es demasiado grande para mostrarlo', + 'diff.error': 'No se pudieron leer los cambios', + 'diff.tooLarge': 'Estos cambios son demasiado grandes para mostrarlos aquí', + 'diff.viewInBrowser': 'Ver en el navegador', + 'diff.noForkPoint': 'No se encontró el punto de bifurcación de esta rama; elige otro ámbito', + 'diff.empty': 'Sin cambios', + 'diff.omitted': 'No se muestran otros {count} archivos sin seguimiento', + 'diff.stale': 'La rama local tiene {count} commits sin enviar; se muestran los cambios de la solicitud', + 'diff.moreActions': 'Más acciones para {name}', + 'diff.copyPath': 'Copiar ruta', + 'diff.openFileInBrowser': 'Ver archivo en el navegador', + 'diff.openFileInSidebar': 'Abrir el archivo completo en la barra lateral', + 'time.now': 'ahora mismo', + 'time.minutes': 'hace {n} min', + 'time.hours': 'hace {n} h', + 'time.days': 'hace {n} d', + 'time.months': 'hace {n} meses', + 'time.years': 'hace {n} años', + 'time.finished': '{state} · {time}', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes} min {seconds} s', + 'duration.hours': '{hours} h {minutes} min', + 'worktree.label': 'Árbol de trabajo', + 'worktree.hint': 'Ejecuta esta sesión en su propio árbol de trabajo: al enviar se crea una rama desde la elegida, sin que los cambios ni los commits afecten al directorio del proyecto', + 'worktree.blocked.not-git': 'Este espacio de trabajo no es un repositorio Git y no puede usar un árbol de trabajo', + 'worktree.blocked.outside-writable-root': 'Los datos Git están fuera de los directorios con permiso de escritura, como cuando el espacio ya es un árbol de trabajo; aún no se puede usar otro árbol', + 'worktree.blocked.read-only': 'Una sesión de solo lectura no puede usar un árbol de trabajo', + 'worktree.base': 'Desde {branch}', + 'worktree.baseHead': 'Desde el HEAD actual', + 'worktree.baseMenu': 'Rama base', + 'worktree.currentBranch': 'Actual', + 'creation.title': 'Creando el árbol de trabajo', + 'creation.failed': 'No se pudo crear el árbol de trabajo', + 'creation.prepare': 'Crear la rama', + 'creation.checkout': 'Extraer archivos', + 'creation.details': 'Más detalles', + 'creation.branch': 'Rama', + 'creation.base': 'Desde', + 'creation.head': 'HEAD actual', + 'creation.error': 'Razón', + 'creation.skip': 'Continuar sin árbol de trabajo', + 'creation.cancel': 'Cancelar', + 'creation.stopped': 'Creación del árbol de trabajo detenida', + 'creation.retry': 'Reintentar', + 'creation.waiting': 'El mensaje se enviará cuando exista el árbol de trabajo', + 'missing.title': 'El árbol de trabajo no está disponible', + 'missing.unknown': 'La restauración está incompleta y no se puede consultar la rama {branch}. Resuelve el error y reintenta, o continúa en el proyecto', + 'missing.kept': 'Se puede restaurar el árbol de la rama {branch} en su ubicación original. Los cambios sin confirmar anteriores a su eliminación no se pueden recuperar', + 'missing.recreated': 'La rama {branch} también se eliminó. Se recreará desde el commit inicial del árbol, sin los commits posteriores ni los cambios sin confirmar', + 'missing.restore': 'Restaurar árbol de trabajo', + 'missing.leave': 'Continuar en el proyecto', + 'missing.waiting': 'El mensaje se enviará cuando restaures el árbol o continúes en el proyecto', + 'restoration.title': 'Restaurando el árbol de trabajo', + 'restoration.prepare': 'Preparar el árbol de trabajo', + 'branch.worktree': 'Árbol de trabajo (desde {base}): {path}', + 'branch.linked': 'Árbol de trabajo: {path}', + 'branch.project': 'Directorio del proyecto: {path}', + 'branch.missing': 'Árbol de trabajo no disponible: {path}', + 'toast.removed': 'Se quitó {name} de esta sesión', + 'toast.undo': 'Deshacer', + 'toast.undoFailed': 'No se pudo deshacer', + 'toast.removeFailed': 'No se pudo quitar', + 'toast.refreshFailed': 'No se pudo actualizar', + 'toast.copied': 'Enlace copiado', + 'toast.copyFailed': 'Copia fallida', + 'toast.branchesFailed': 'No se pudieron leer las ramas', + 'toast.worktreeFailed': 'No se pudo cambiar la opción del árbol de trabajo', + 'toast.worktreeStarted': 'Esta sesión ya empezó y no puede pasar a un árbol de trabajo', + 'toast.restored': 'Árbol de trabajo restaurado', + 'toast.restoreFailed': 'No se pudo restaurar el árbol de trabajo: {message}', + 'toast.leaveFailed': 'No se pudo cambiar al directorio del proyecto; inténtalo de nuevo', + 'toast.markedReady': '{name} está lista para revisión', + 'toast.markReadyFailed': 'No se pudo marcar como lista: {message}', + 'toast.merged': 'Se fusionó {name}', + 'toast.mergeFailed': 'No se pudo fusionar: {message}', + 'toast.mergeStale': '{name} tiene nuevos commits; revísalos antes de fusionar', + 'toast.pathCopied': 'Ruta copiada', + 'toast.expandFailed': 'No se pudieron mostrar las líneas sin cambios: {message}', + 'toast.expandStale': 'El archivo cambió después de leer sus cambios; actualiza antes de expandir', + 'toast.prompted': 'Enviado: {text}', + 'toast.promptFailed': 'No se pudo enviar el mensaje', +} + +/** French copy with the same keys. */ +export const fr: Record = { + 'tab.title': 'Demandes de modification', + 'header.label': 'Demandes de modification de cette session', + 'header.attention': 'Les demandes de modification de cette session nécessitent une intervention', + 'checkout.worktree': 'Arbre de travail', + 'checkout.worktreeFrom': 'Arbre de travail · depuis {base}', + 'checkout.project': 'Dossier du projet', + 'checkout.detached': 'Aucune branche active', + 'checkout.restore': 'Restaurer', + 'checkout.copyWorktreePath': 'Copier le chemin de l’arbre de travail', + 'checkout.copyProjectPath': 'Copier le chemin du projet', + 'checkout.none': 'Ce dossier n’est pas un dépôt Git', + 'checkout.failed': 'Impossible de lire les informations Git', + 'changes.title': 'Demandes de modification de cette session · {count}', + 'changes.empty': 'Cette session n’a pas encore de demande de modification', + 'changes.emptyHint': 'Les demandes liées à cette session apparaissent ici : créées, consultées, modifiées ou extraites, ou ouvertes sur ses branches actuelles ou envoyées', + 'changes.local.title': 'Modifications', + 'changes.local.refs': '{head} → {base}', + 'changes.local.files': '{count} fichiers', + 'changes.local.viewAria': 'Voir les modifications de cette session : {count} fichiers, {added} lignes ajoutées et {deleted} supprimées', + 'changes.local.create': 'Créer une demande de modification', + 'changes.local.prompt': 'Créez une demande de modification pour les changements de cette session', + 'signin.title': 'Connectez-vous à {host} pour voir l’état des demandes', + 'signin.copy': 'Copier la commande de connexion', + 'signin.unavailable': 'Impossible de lire les demandes sur {host} : {message}', + 'refresh': 'Actualiser l’état', + 'retry': 'Réessayer', + 'stream.failed': 'Impossible de charger le panneau des demandes de modification', + 'change.number': '#{number}', + 'change.stateAria': 'État de la demande : {state}', + 'change.state.open': 'Ouverte', + 'change.state.blocked': 'Intervention requise', + 'change.state.draft': 'Brouillon', + 'change.state.merged': 'Fusionnée', + 'change.state.closed': 'Fermée', + 'change.state.unknown': 'Inconnue', + 'change.expand': 'Développer {name}', + 'change.collapse': 'Réduire {name}', + 'change.ci.none': 'Aucune vérification', + 'change.ci.running': 'Vérifications en cours', + 'change.ci.passed': 'Vérifications réussies', + 'change.ci.failed': '{count} vérifications échouées', + 'change.ci.failedUnlisted': 'Échec des vérifications', + 'change.checks.failed': '{count} échouées', + 'change.checks.running': '{count} en cours', + 'change.checks.passed': '{count} réussies', + 'change.checks.other': '{count} autres', + 'change.checks.truncated': 'Seules les {count} premières sont affichées', + 'change.check.running': 'En cours', + 'change.check.queued': 'En file d\'attente', + 'change.flag.changesRequested': 'Modifications demandées', + 'change.flag.conflicting': 'Conflits', + 'change.flag.draft': 'Brouillon', + 'change.failure.unavailable': 'Impossible de lire l’état de la demande', + 'change.failure.notFound': 'La demande n’existe pas ou n’est pas accessible', + 'change.failure.unauthenticated': 'Connectez-vous pour voir son état', + 'change.unserved': 'L’état des demandes n’est pas encore pris en charge pour cet hébergeur de code', + 'change.view': 'Ouvrir dans le navigateur', + 'change.copy': 'Copier le lien de la demande', + 'change.viewChangesAria': 'Voir les modifications de {name} : {added} lignes ajoutées et {deleted} supprimées', + 'change.remove': 'Retirer de cette session', + 'change.removeAria': 'Retirer {name} de cette session', + 'change.row.checks': 'Vérifications', + 'change.row.review': 'Revue', + 'change.row.merge': 'Fusionner', + 'change.row.changes': 'Modifications', + 'change.review.approved': 'Approuvée', + 'change.review.changesRequested': 'Modifications demandées', + 'change.review.required': 'Revue requise', + 'change.review.none': 'Aucune revue demandée', + 'change.review.threads': '{review} · {count} non résolus', + 'change.merge.mergeable': 'Aucun conflit', + 'change.merge.conflicting': 'Conflits avec {base}', + 'change.merge.unknown': 'Recherche de conflits', + 'change.mergedTo': 'Fusionnée dans {base}', + 'change.markReady': 'Prête pour la revue', + 'change.merge': 'Fusionner', + 'change.mergeTitle': 'Fusionner {name} dans {base}', + 'change.mergeMethod.squash': 'Fusionner par squash', + 'change.mergeMethod.merge': 'Créer un commit de fusion', + 'change.mergeMethod.rebase': 'Rebaser et fusionner', + 'change.mergeNote.squash': 'Regrouper tous les commits en un seul', + 'change.mergeNote.merge': 'Conserver tous les commits et ajouter un commit de fusion', + 'change.mergeNote.rebase': 'Rejouer chaque commit sur {base}', + 'change.mergeConfirm': 'Confirmer la fusion', + 'change.mergeChanged': 'De nouveaux commits sont arrivés. Vérifiez-les avant de rouvrir le menu de fusion', + 'diff.tab': 'Modifications', + 'diff.tabNumbered': 'Modifications de #{number}', + 'diff.scope.branch': 'Branche', + 'diff.scope.uncommitted': 'Non commité', + 'diff.scope.unstaged': 'Non indexé', + 'diff.scope.staged': 'Indexé', + 'diff.scopeNote.branch': 'Comparaison avec {base}, modifications non commitées incluses', + 'diff.scopeNote.uncommitted': 'Comparaison avec HEAD, fichiers non suivis inclus', + 'diff.scopeNote.unstaged': 'Comparaison avec l’index, fichiers non suivis inclus', + 'diff.scopeNote.staged': 'Comparaison de l’index avec HEAD', + 'diff.allFiles': 'Les {count} fichiers', + 'diff.refresh': 'Actualiser les modifications', + 'diff.expandAll': 'Développer tous les fichiers', + 'diff.collapseAll': 'Réduire tous les fichiers', + 'diff.split': 'Afficher côte à côte', + 'diff.unified': 'Afficher sur une colonne', + 'diff.splitAria': 'Côte à côte', + 'diff.wrap': 'Replier les lignes longues', + 'diff.nowrap': 'Ne pas replier les lignes longues', + 'diff.wrapAria': 'Activer le retour à la ligne', + 'diff.unchanged': '{count} lignes inchangées', + 'diff.unchangedRest': 'Lignes inchangées restantes', + 'diff.newFile': 'Nouveau fichier', + 'diff.deletedFile': 'Supprimé', + 'diff.renamed': 'Renommé depuis {from}', + 'diff.renamedPath': '{from} → {path}', + 'diff.badgeUncommitted': 'Non commité', + 'diff.binary': 'Fichier binaire ; ses modifications ne peuvent pas être affichées', + 'diff.binaryLabel': 'Binaire', + 'diff.largeCollapsed': 'Fichier volumineux : {count} lignes modifiées réduites', + 'diff.fileTooLarge': 'Ce fichier est trop volumineux pour être affiché', + 'diff.error': 'Impossible de lire les modifications', + 'diff.tooLarge': 'Ces modifications sont trop volumineuses pour être affichées ici', + 'diff.viewInBrowser': 'Voir dans le navigateur', + 'diff.noForkPoint': 'Le point de divergence de cette branche est introuvable ; choisissez une autre portée', + 'diff.empty': 'Aucune modification', + 'diff.omitted': '{count} autres fichiers non suivis ne sont pas affichés', + 'diff.stale': 'La branche locale contient {count} commits non envoyés ; les modifications de la demande sont affichées', + 'diff.moreActions': 'Autres actions pour {name}', + 'diff.copyPath': 'Copier le chemin', + 'diff.openFileInBrowser': 'Voir le fichier dans le navigateur', + 'diff.openFileInSidebar': 'Ouvrir le fichier entier dans la barre latérale', + 'time.now': 'à l’instant', + 'time.minutes': 'il y a {n} min', + 'time.hours': 'il y a {n} h', + 'time.days': 'il y a {n} j', + 'time.months': 'il y a {n} mois', + 'time.years': 'il y a {n} ans', + 'time.finished': '{state} · {time}', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes} min {seconds} s', + 'duration.hours': '{hours} h {minutes} min', + 'worktree.label': 'Arbre de travail', + 'worktree.hint': 'Exécutez cette session dans son propre arbre de travail : l’envoi crée une branche depuis celle choisie, sans que les modifications ou les commits affectent le dossier du projet', + 'worktree.blocked.not-git': 'Cet espace de travail n’est pas un dépôt Git et ne peut pas utiliser d’arbre de travail', + 'worktree.blocked.outside-writable-root': 'Les données Git sont hors des dossiers accessibles en écriture, notamment lorsque l’espace est déjà un arbre de travail ; cette fonction n’est pas encore disponible', + 'worktree.blocked.read-only': 'Une session en lecture seule ne peut pas utiliser d’arbre de travail', + 'worktree.base': 'Depuis {branch}', + 'worktree.baseHead': 'Depuis le HEAD actuel', + 'worktree.baseMenu': 'Branche de base', + 'worktree.currentBranch': 'Actuelle', + 'creation.title': 'Création de l’arbre de travail', + 'creation.failed': 'Impossible de créer l’arbre de travail', + 'creation.prepare': 'Créer la branche', + 'creation.checkout': 'Extraire les fichiers', + 'creation.details': 'Plus de détails', + 'creation.branch': 'Branche', + 'creation.base': 'Depuis', + 'creation.head': 'HEAD actuel', + 'creation.error': 'Raison', + 'creation.skip': 'Continuer sans arbre de travail', + 'creation.cancel': 'Annuler', + 'creation.stopped': 'Création de l’arbre de travail arrêtée', + 'creation.retry': 'Réessayer', + 'creation.waiting': 'Le message sera envoyé après la création de l’arbre de travail', + 'missing.title': 'L’arbre de travail est indisponible', + 'missing.unknown': 'La restauration est incomplète et la branche {branch} ne peut pas être inspectée. Corrigez l’erreur puis réessayez, ou continuez dans le projet', + 'missing.kept': 'L’arbre de la branche {branch} peut être restauré à son emplacement initial. Les modifications non commitées avant sa suppression ne peuvent pas être récupérées', + 'missing.recreated': 'La branche {branch} a aussi été supprimée. Elle sera recréée depuis le commit initial de l’arbre, sans les commits ultérieurs ni les modifications non commitées', + 'missing.restore': 'Restaurer l’arbre de travail', + 'missing.leave': 'Continuer dans le projet', + 'missing.waiting': 'Le message sera envoyé après la restauration de l’arbre ou la poursuite dans le projet', + 'restoration.title': 'Restauration de l’arbre de travail', + 'restoration.prepare': 'Préparer l’arbre de travail', + 'branch.worktree': 'Arbre de travail (depuis {base}) : {path}', + 'branch.linked': 'Arbre de travail : {path}', + 'branch.project': 'Dossier du projet : {path}', + 'branch.missing': 'Arbre de travail indisponible : {path}', + 'toast.removed': '{name} a été retiré de cette session', + 'toast.undo': 'Annuler', + 'toast.undoFailed': 'Impossible d’annuler', + 'toast.removeFailed': 'Impossible de retirer', + 'toast.refreshFailed': 'Échec de l’actualisation', + 'toast.copied': 'Lien copié', + 'toast.copyFailed': 'Échec de la copie', + 'toast.branchesFailed': 'Impossible de lire les branches', + 'toast.worktreeFailed': 'Impossible de modifier l’option de l’arbre de travail', + 'toast.worktreeStarted': 'Cette session a déjà commencé et ne peut plus passer à un arbre de travail', + 'toast.restored': 'Arbre de travail restauré', + 'toast.restoreFailed': 'Impossible de restaurer l’arbre de travail : {message}', + 'toast.leaveFailed': 'Impossible de passer au dossier du projet ; réessayez', + 'toast.markedReady': '{name} est prête pour la revue', + 'toast.markReadyFailed': 'Impossible de la marquer comme prête : {message}', + 'toast.merged': '{name} a été fusionné', + 'toast.mergeFailed': 'Impossible de fusionner : {message}', + 'toast.mergeStale': '{name} contient de nouveaux commits ; vérifiez-les avant de fusionner', + 'toast.pathCopied': 'Chemin copié', + 'toast.expandFailed': 'Impossible d’afficher les lignes inchangées : {message}', + 'toast.expandStale': 'Le fichier a changé depuis la lecture des modifications ; actualisez avant de développer', + 'toast.prompted': 'Envoyé : {text}', + 'toast.promptFailed': 'Impossible d’envoyer le message', +} + +/** German copy with the same keys. */ +export const de: Record = { + 'tab.title': 'Änderungsanfragen', + 'header.label': 'Änderungsanfragen dieser Sitzung', + 'header.attention': 'Änderungsanfragen dieser Sitzung erfordern Aufmerksamkeit', + 'checkout.worktree': 'Arbeitsbaum', + 'checkout.worktreeFrom': 'Arbeitsbaum · aus {base}', + 'checkout.project': 'Projektverzeichnis', + 'checkout.detached': 'Auf keinem Branch', + 'checkout.restore': 'Wiederherstellen', + 'checkout.copyWorktreePath': 'Pfad des Arbeitsbaums kopieren', + 'checkout.copyProjectPath': 'Projektpfad kopieren', + 'checkout.none': 'Dieses Verzeichnis ist kein Git-Repository', + 'checkout.failed': 'Git-Informationen konnten nicht gelesen werden', + 'changes.title': 'Änderungsanfragen dieser Sitzung · {count}', + 'changes.empty': 'Diese Sitzung hat noch keine Änderungsanfragen', + 'changes.emptyHint': 'Hier erscheinen verknüpfte Änderungsanfragen: in dieser Sitzung erstellt, angesehen, bearbeitet oder ausgecheckt oder auf ihren aktuellen oder gepushten Branches geöffnet', + 'changes.local.title': 'Änderungen', + 'changes.local.refs': '{head} → {base}', + 'changes.local.files': '{count} Dateien', + 'changes.local.viewAria': 'Änderungen dieser Sitzung anzeigen: {count} Dateien, {added} Zeilen hinzugefügt, {deleted} Zeilen gelöscht', + 'changes.local.create': 'Änderungsanfrage erstellen', + 'changes.local.prompt': 'Erstelle eine Änderungsanfrage für die Änderungen dieser Sitzung', + 'signin.title': 'Bei {host} anmelden, um den Status der Änderungsanfragen anzuzeigen', + 'signin.copy': 'Anmeldebefehl kopieren', + 'signin.unavailable': 'Änderungsanfragen auf {host} konnten nicht gelesen werden: {message}', + 'refresh': 'Status aktualisieren', + 'retry': 'Erneut versuchen', + 'stream.failed': 'Das Panel für Änderungsanfragen konnte nicht geladen werden', + 'change.number': '#{number}', + 'change.stateAria': 'Status der Änderungsanfrage: {state}', + 'change.state.open': 'Offen', + 'change.state.blocked': 'Aufmerksamkeit erforderlich', + 'change.state.draft': 'Entwurf', + 'change.state.merged': 'Zusammengeführt', + 'change.state.closed': 'Geschlossen', + 'change.state.unknown': 'Unbekannt', + 'change.expand': '{name} aufklappen', + 'change.collapse': '{name} einklappen', + 'change.ci.none': 'Keine Prüfungen', + 'change.ci.running': 'Prüfungen laufen', + 'change.ci.passed': 'Prüfungen bestanden', + 'change.ci.failed': '{count} Prüfungen fehlgeschlagen', + 'change.ci.failedUnlisted': 'Prüfungen fehlgeschlagen', + 'change.checks.failed': '{count} fehlgeschlagen', + 'change.checks.running': '{count} laufen', + 'change.checks.passed': '{count} bestanden', + 'change.checks.other': '{count} weitere', + 'change.checks.truncated': 'Nur die ersten {count} werden angezeigt', + 'change.check.running': 'Läuft', + 'change.check.queued': 'In der Warteschlange', + 'change.flag.changesRequested': 'Änderungen angefordert', + 'change.flag.conflicting': 'Konflikte', + 'change.flag.draft': 'Entwurf', + 'change.failure.unavailable': 'Der Status der Änderungsanfrage konnte nicht gelesen werden', + 'change.failure.notFound': 'Die Änderungsanfrage existiert nicht oder ist nicht zugänglich', + 'change.failure.unauthenticated': 'Anmelden, um den Status zu lesen', + 'change.unserved': 'Der Status von Änderungsanfragen wird für diesen Code-Host noch nicht unterstützt', + 'change.view': 'Im Browser öffnen', + 'change.copy': 'Link zur Änderungsanfrage kopieren', + 'change.viewChangesAria': 'Änderungen von {name} anzeigen: {added} Zeilen hinzugefügt, {deleted} Zeilen gelöscht', + 'change.remove': 'Aus dieser Sitzung entfernen', + 'change.removeAria': '{name} aus dieser Sitzung entfernen', + 'change.row.checks': 'Prüfungen', + 'change.row.review': 'Review', + 'change.row.merge': 'Zusammenführen', + 'change.row.changes': 'Änderungen', + 'change.review.approved': 'Genehmigt', + 'change.review.changesRequested': 'Änderungen angefordert', + 'change.review.required': 'Review erforderlich', + 'change.review.none': 'Kein Review angefordert', + 'change.review.threads': '{review} · {count} ungelöst', + 'change.merge.mergeable': 'Keine Konflikte', + 'change.merge.conflicting': 'Konflikte mit {base}', + 'change.merge.unknown': 'Konflikte werden geprüft', + 'change.mergedTo': 'In {base} zusammengeführt', + 'change.markReady': 'Bereit zum Review', + 'change.merge': 'Zusammenführen', + 'change.mergeTitle': '{name} in {base} zusammenführen', + 'change.mergeMethod.squash': 'Squash und zusammenführen', + 'change.mergeMethod.merge': 'Merge-Commit erstellen', + 'change.mergeMethod.rebase': 'Rebase und zusammenführen', + 'change.mergeNote.squash': 'Alle Commits zu einem zusammenfassen', + 'change.mergeNote.merge': 'Alle Commits beibehalten und einen Merge-Commit hinzufügen', + 'change.mergeNote.rebase': 'Jeden Commit auf {base} neu anwenden', + 'change.mergeConfirm': 'Zusammenführung bestätigen', + 'change.mergeChanged': 'Neue Commits sind eingetroffen. Vor dem erneuten Öffnen des Merge-Menüs prüfen', + 'diff.tab': 'Änderungen', + 'diff.tabNumbered': 'Änderungen in #{number}', + 'diff.scope.branch': 'Branch', + 'diff.scope.uncommitted': 'Nicht committet', + 'diff.scope.unstaged': 'Nicht vorgemerkt', + 'diff.scope.staged': 'Vorgemerkt', + 'diff.scopeNote.branch': 'Vergleich mit {base}, einschließlich nicht committeter Änderungen', + 'diff.scopeNote.uncommitted': 'Vergleich mit HEAD, einschließlich nicht verfolgter Dateien', + 'diff.scopeNote.unstaged': 'Vergleich mit dem Index, einschließlich nicht verfolgter Dateien', + 'diff.scopeNote.staged': 'Vergleich des Index mit HEAD', + 'diff.allFiles': 'Alle {count} Dateien', + 'diff.refresh': 'Änderungen aktualisieren', + 'diff.expandAll': 'Alle Dateien aufklappen', + 'diff.collapseAll': 'Alle Dateien einklappen', + 'diff.split': 'Nebeneinander anzeigen', + 'diff.unified': 'In einer Spalte anzeigen', + 'diff.splitAria': 'Nebeneinander', + 'diff.wrap': 'Lange Zeilen umbrechen', + 'diff.nowrap': 'Lange Zeilen nicht umbrechen', + 'diff.wrapAria': 'Zeilen umbrechen', + 'diff.unchanged': '{count} unveränderte Zeilen', + 'diff.unchangedRest': 'Übrige unveränderte Zeilen', + 'diff.newFile': 'Neue Datei', + 'diff.deletedFile': 'Gelöscht', + 'diff.renamed': 'Umbenannt von {from}', + 'diff.renamedPath': '{from} → {path}', + 'diff.badgeUncommitted': 'Nicht committet', + 'diff.binary': 'Binärdatei; Änderungen können nicht angezeigt werden', + 'diff.binaryLabel': 'Binär', + 'diff.largeCollapsed': 'Große Datei: {count} geänderte Zeilen eingeklappt', + 'diff.fileTooLarge': 'Diese Datei ist zu groß zum Anzeigen', + 'diff.error': 'Änderungen konnten nicht gelesen werden', + 'diff.tooLarge': 'Diese Änderungen sind zu groß, um sie hier anzuzeigen', + 'diff.viewInBrowser': 'Im Browser ansehen', + 'diff.noForkPoint': 'Kein Verzweigungspunkt für diesen Branch gefunden; einen anderen Bereich wählen', + 'diff.empty': 'Keine Änderungen', + 'diff.omitted': '{count} weitere nicht verfolgte Dateien werden nicht angezeigt', + 'diff.stale': 'Der lokale Branch hat {count} nicht gepushte Commits; angezeigt werden die Änderungen der Änderungsanfrage', + 'diff.moreActions': 'Weitere Aktionen für {name}', + 'diff.copyPath': 'Pfad kopieren', + 'diff.openFileInBrowser': 'Datei im Browser ansehen', + 'diff.openFileInSidebar': 'Die gesamte Datei in der Seitenleiste öffnen', + 'time.now': 'gerade eben', + 'time.minutes': 'vor {n} Min.', + 'time.hours': 'vor {n} Std.', + 'time.days': 'vor {n} Tagen', + 'time.months': 'vor {n} Monaten', + 'time.years': 'vor {n} Jahren', + 'time.finished': '{state} · {time}', + 'duration.seconds': '{seconds} Sek.', + 'duration.minutes': '{minutes} Min. {seconds} Sek.', + 'duration.hours': '{hours} Std. {minutes} Min.', + 'worktree.label': 'Arbeitsbaum', + 'worktree.hint': 'Diese Sitzung in einem eigenen Arbeitsbaum ausführen: Beim Senden wird aus dem gewählten Branch ein neuer erstellt; Änderungen und Commits lassen das Projektverzeichnis unverändert', + 'worktree.blocked.not-git': 'Dieser Arbeitsbereich ist kein Git-Repository und kann keinen Arbeitsbaum verwenden', + 'worktree.blocked.outside-writable-root': 'Die Git-Daten liegen außerhalb der beschreibbaren Verzeichnisse, etwa wenn der Arbeitsbereich selbst ein Arbeitsbaum ist; Arbeitsbäume werden hier noch nicht unterstützt', + 'worktree.blocked.read-only': 'Eine schreibgeschützte Sitzung kann keinen Arbeitsbaum verwenden', + 'worktree.base': 'Aus {branch}', + 'worktree.baseHead': 'Aus dem aktuellen HEAD', + 'worktree.baseMenu': 'Basis-Branch', + 'worktree.currentBranch': 'Aktuell', + 'creation.title': 'Arbeitsbaum wird erstellt', + 'creation.failed': 'Arbeitsbaum konnte nicht erstellt werden', + 'creation.prepare': 'Branch erstellen', + 'creation.checkout': 'Dateien auschecken', + 'creation.details': 'Weitere Details', + 'creation.branch': 'Branch', + 'creation.base': 'Basis', + 'creation.head': 'Aktueller HEAD', + 'creation.error': 'Grund', + 'creation.skip': 'Ohne Arbeitsbaum fortfahren', + 'creation.cancel': 'Abbrechen', + 'creation.stopped': 'Erstellung des Arbeitsbaums angehalten', + 'creation.retry': 'Erneut versuchen', + 'creation.waiting': 'Die Nachricht wird gesendet, sobald der Arbeitsbaum erstellt ist', + 'missing.title': 'Arbeitsbaum nicht verfügbar', + 'missing.unknown': 'Die Wiederherstellung ist unvollständig und Branch {branch} kann nicht geprüft werden. Den Fehler beheben und erneut versuchen oder im Projekt fortfahren', + 'missing.kept': 'Der Arbeitsbaum von Branch {branch} kann am ursprünglichen Ort wiederhergestellt werden. Nicht committete Änderungen vor dem Entfernen können nicht wiederhergestellt werden', + 'missing.recreated': 'Branch {branch} wurde ebenfalls entfernt. Er wird vom ursprünglichen Commit des Arbeitsbaums neu erstellt, ohne spätere Commits oder nicht committete Änderungen', + 'missing.restore': 'Arbeitsbaum wiederherstellen', + 'missing.leave': 'Im Projekt fortfahren', + 'missing.waiting': 'Die Nachricht wird gesendet, nachdem der Arbeitsbaum wiederhergestellt wurde oder Sie im Projekt fortfahren', + 'restoration.title': 'Arbeitsbaum wird wiederhergestellt', + 'restoration.prepare': 'Arbeitsbaum vorbereiten', + 'branch.worktree': 'Arbeitsbaum (aus {base}): {path}', + 'branch.linked': 'Arbeitsbaum: {path}', + 'branch.project': 'Projektverzeichnis: {path}', + 'branch.missing': 'Arbeitsbaum nicht verfügbar: {path}', + 'toast.removed': '{name} wurde aus dieser Sitzung entfernt', + 'toast.undo': 'Rückgängig machen', + 'toast.undoFailed': 'Rückgängig machen fehlgeschlagen', + 'toast.removeFailed': 'Entfernen fehlgeschlagen', + 'toast.refreshFailed': 'Aktualisierung fehlgeschlagen', + 'toast.copied': 'Link kopiert', + 'toast.copyFailed': 'Das Kopieren ist fehlgeschlagen', + 'toast.branchesFailed': 'Branches konnten nicht gelesen werden', + 'toast.worktreeFailed': 'Die Arbeitsbaum-Option konnte nicht geändert werden', + 'toast.worktreeStarted': 'Diese Sitzung wurde bereits gestartet und kann nicht mehr zu einem Arbeitsbaum wechseln', + 'toast.restored': 'Arbeitsbaum wiederhergestellt', + 'toast.restoreFailed': 'Arbeitsbaum konnte nicht wiederhergestellt werden: {message}', + 'toast.leaveFailed': 'Wechsel zum Projektverzeichnis fehlgeschlagen; erneut versuchen', + 'toast.markedReady': '{name} ist zum Review bereit', + 'toast.markReadyFailed': 'Konnte nicht als bereit markiert werden: {message}', + 'toast.merged': '{name} wurde zusammengeführt', + 'toast.mergeFailed': 'Zusammenführung fehlgeschlagen: {message}', + 'toast.mergeStale': '{name} enthält neue Commits; vor dem Zusammenführen prüfen', + 'toast.pathCopied': 'Pfad kopiert', + 'toast.expandFailed': 'Unveränderte Zeilen konnten nicht angezeigt werden: {message}', + 'toast.expandStale': 'Die Datei wurde nach dem Lesen der Änderungen verändert; vor dem Aufklappen aktualisieren', + 'toast.prompted': 'Gesendet: {text}', + 'toast.promptFailed': 'Nachricht konnte nicht gesendet werden', +} + +/** Russian copy with the same keys. */ +export const ru: Record = { + 'tab.title': 'Запросы на изменение', + 'header.label': 'Запросы на изменение этого сеанса', + 'header.attention': 'Запросы на изменение этого сеанса требуют внимания', + 'checkout.worktree': 'Рабочее дерево', + 'checkout.worktreeFrom': 'Рабочее дерево · на основе {base}', + 'checkout.project': 'Каталог проекта', + 'checkout.detached': 'Вне ветки', + 'checkout.restore': 'Восстановить', + 'checkout.copyWorktreePath': 'Копировать путь рабочего дерева', + 'checkout.copyProjectPath': 'Копировать путь проекта', + 'checkout.none': 'Этот каталог не является репозиторием Git', + 'checkout.failed': 'Не удалось прочитать сведения Git', + 'changes.title': 'Запросы на изменение этого сеанса · {count}', + 'changes.empty': 'В этом сеансе пока нет запросов на изменение', + 'changes.emptyHint': 'Здесь отображаются связанные с сеансом запросы: созданные, просмотренные, изменённые или извлечённые, а также открытые для текущей или отправленной ветки', + 'changes.local.title': 'Изменения', + 'changes.local.refs': '{head} → {base}', + 'changes.local.files': 'Файлов: {count}', + 'changes.local.viewAria': 'Просмотреть изменения сеанса: файлов — {count}, добавлено строк — {added}, удалено — {deleted}', + 'changes.local.create': 'Создать запрос на изменение', + 'changes.local.prompt': 'Создай запрос на изменение для изменений этого сеанса', + 'signin.title': 'Войдите на {host}, чтобы увидеть состояние запросов', + 'signin.copy': 'Копировать команду входа', + 'signin.unavailable': 'Не удалось прочитать запросы на {host}: {message}', + 'refresh': 'Обновить состояние', + 'retry': 'Повторить', + 'stream.failed': 'Не удалось загрузить панель запросов на изменение', + 'change.number': '#{number}', + 'change.stateAria': 'Состояние запроса: {state}', + 'change.state.open': 'Открыт', + 'change.state.blocked': 'Требует внимания', + 'change.state.draft': 'Черновик', + 'change.state.merged': 'Объединён', + 'change.state.closed': 'Закрыт', + 'change.state.unknown': 'Неизвестен', + 'change.expand': 'Развернуть {name}', + 'change.collapse': 'Свернуть {name}', + 'change.ci.none': 'Нет проверок', + 'change.ci.running': 'Проверки выполняются', + 'change.ci.passed': 'Проверки пройдены', + 'change.ci.failed': 'Не пройдено проверок: {count}', + 'change.ci.failedUnlisted': 'Проверки не пройдены', + 'change.checks.failed': 'Не пройдено: {count}', + 'change.checks.running': 'Выполняется: {count}', + 'change.checks.passed': 'Пройдено: {count}', + 'change.checks.other': 'Других: {count}', + 'change.checks.truncated': 'Показаны только первые {count}', + 'change.check.running': 'Выполняется', + 'change.check.queued': 'В очереди', + 'change.flag.changesRequested': 'Запрошены изменения', + 'change.flag.conflicting': 'Конфликты', + 'change.flag.draft': 'Черновик', + 'change.failure.unavailable': 'Не удалось прочитать состояние запроса', + 'change.failure.notFound': 'Запрос не существует или недоступен', + 'change.failure.unauthenticated': 'Войдите, чтобы увидеть состояние', + 'change.unserved': 'Состояние запросов этого хостинга кода пока не поддерживается', + 'change.view': 'Открыть в браузере', + 'change.copy': 'Копировать ссылку на запрос', + 'change.viewChangesAria': 'Просмотреть изменения {name}: добавлено строк — {added}, удалено — {deleted}', + 'change.remove': 'Удалить из этого сеанса', + 'change.removeAria': 'Удалить {name} из этого сеанса', + 'change.row.checks': 'Проверки', + 'change.row.review': 'Ревью', + 'change.row.merge': 'Объединить', + 'change.row.changes': 'Изменения', + 'change.review.approved': 'Одобрен', + 'change.review.changesRequested': 'Запрошены изменения', + 'change.review.required': 'Требуется ревью', + 'change.review.none': 'Ревью не запрошено', + 'change.review.threads': '{review} · не решено: {count}', + 'change.merge.mergeable': 'Нет конфликтов', + 'change.merge.conflicting': 'Конфликты с {base}', + 'change.merge.unknown': 'Проверка конфликтов', + 'change.mergedTo': 'Объединён с {base}', + 'change.markReady': 'Готов к ревью', + 'change.merge': 'Объединить', + 'change.mergeTitle': 'Объединить {name} с {base}', + 'change.mergeMethod.squash': 'Объединить со сжатием', + 'change.mergeMethod.merge': 'Создать коммит слияния', + 'change.mergeMethod.rebase': 'Перебазировать и объединить', + 'change.mergeNote.squash': 'Объединить все коммиты в один', + 'change.mergeNote.merge': 'Сохранить все коммиты и добавить коммит слияния', + 'change.mergeNote.rebase': 'Перенести каждый коммит на {base}', + 'change.mergeConfirm': 'Подтвердить слияние', + 'change.mergeChanged': 'Появились новые коммиты. Проверьте их перед повторным открытием меню слияния', + 'diff.tab': 'Изменения', + 'diff.tabNumbered': 'Изменения в #{number}', + 'diff.scope.branch': 'Ветка', + 'diff.scope.uncommitted': 'Без коммита', + 'diff.scope.unstaged': 'Не в индексе', + 'diff.scope.staged': 'В индексе', + 'diff.scopeNote.branch': 'Сравнение с {base}, включая изменения без коммита', + 'diff.scopeNote.uncommitted': 'Сравнение с HEAD, включая неотслеживаемые файлы', + 'diff.scopeNote.unstaged': 'Сравнение с индексом, включая неотслеживаемые файлы', + 'diff.scopeNote.staged': 'Сравнение индекса с HEAD', + 'diff.allFiles': 'Все файлы: {count}', + 'diff.refresh': 'Обновить изменения', + 'diff.expandAll': 'Развернуть все файлы', + 'diff.collapseAll': 'Свернуть все файлы', + 'diff.split': 'Показать рядом', + 'diff.unified': 'Показать в один столбец', + 'diff.splitAria': 'Рядом', + 'diff.wrap': 'Переносить длинные строки', + 'diff.nowrap': 'Не переносить длинные строки', + 'diff.wrapAria': 'Переносить строки', + 'diff.unchanged': 'Неизменённых строк: {count}', + 'diff.unchangedRest': 'Остальные неизменённые строки', + 'diff.newFile': 'Новый файл', + 'diff.deletedFile': 'Удалено', + 'diff.renamed': 'Переименован из {from}', + 'diff.renamedPath': '{from} → {path}', + 'diff.badgeUncommitted': 'Без коммита', + 'diff.binary': 'Двоичный файл; невозможно показать изменения', + 'diff.binaryLabel': 'Двоичный', + 'diff.largeCollapsed': 'Большой файл: свёрнуто изменённых строк — {count}', + 'diff.fileTooLarge': 'Файл слишком большой для отображения', + 'diff.error': 'Не удалось прочитать изменения', + 'diff.tooLarge': 'Изменения слишком велики для отображения здесь', + 'diff.viewInBrowser': 'Посмотреть в браузере', + 'diff.noForkPoint': 'Не найдена точка ответвления этой ветки; выберите другую область', + 'diff.empty': 'Нет изменений', + 'diff.omitted': 'Не показано ещё неотслеживаемых файлов: {count}', + 'diff.stale': 'В локальной ветке {count} неотправленных коммитов; показаны изменения из запроса', + 'diff.moreActions': 'Другие действия для {name}', + 'diff.copyPath': 'Копировать путь', + 'diff.openFileInBrowser': 'Посмотреть файл в браузере', + 'diff.openFileInSidebar': 'Открыть весь файл в боковой панели', + 'time.now': 'только что', + 'time.minutes': '{n} мин назад', + 'time.hours': '{n} ч назад', + 'time.days': '{n} дн назад', + 'time.months': '{n} мес назад', + 'time.years': '{n} г назад', + 'time.finished': '{state} · {time}', + 'duration.seconds': '{seconds} с', + 'duration.minutes': '{minutes} мин {seconds} с', + 'duration.hours': '{hours} ч {minutes} мин', + 'worktree.label': 'Рабочее дерево', + 'worktree.hint': 'Запустить сеанс в отдельном рабочем дереве: при отправке будет создана ветка на основе выбранной, а изменения и коммиты не затронут каталог проекта', + 'worktree.blocked.not-git': 'Рабочая область не является репозиторием Git, поэтому рабочее дерево недоступно', + 'worktree.blocked.outside-writable-root': 'Данные Git находятся вне доступных для записи каталогов, например когда рабочая область сама является рабочим деревом; создание рабочего дерева пока недоступно', + 'worktree.blocked.read-only': 'Сеанс только для чтения не может использовать рабочее дерево', + 'worktree.base': 'На основе {branch}', + 'worktree.baseHead': 'На основе текущего HEAD', + 'worktree.baseMenu': 'Базовая ветка', + 'worktree.currentBranch': 'Текущая', + 'creation.title': 'Создание рабочего дерева', + 'creation.failed': 'Не удалось создать рабочее дерево', + 'creation.prepare': 'Создать ветку', + 'creation.checkout': 'Извлечь файлы', + 'creation.details': 'Подробнее', + 'creation.branch': 'Ветка', + 'creation.base': 'Основа', + 'creation.head': 'Текущий HEAD', + 'creation.error': 'Причина', + 'creation.skip': 'Продолжить без рабочего дерева', + 'creation.cancel': 'Отмена', + 'creation.stopped': 'Создание рабочего дерева остановлено', + 'creation.retry': 'Повторить', + 'creation.waiting': 'Сообщение будет отправлено после создания рабочего дерева', + 'missing.title': 'Рабочее дерево недоступно', + 'missing.unknown': 'Восстановление не завершено, ветка {branch} недоступна для проверки. Устраните ошибку и повторите попытку или продолжите в проекте', + 'missing.kept': 'Рабочее дерево ветки {branch} можно восстановить на прежнем месте. Изменения без коммита, существовавшие до удаления, восстановить нельзя', + 'missing.recreated': 'Ветка {branch} тоже удалена. Она будет создана заново из исходного коммита рабочего дерева, без последующих коммитов и изменений без коммита', + 'missing.restore': 'Восстановить рабочее дерево', + 'missing.leave': 'Продолжить в проекте', + 'missing.waiting': 'Сообщение будет отправлено после восстановления рабочего дерева или продолжения в проекте', + 'restoration.title': 'Восстановление рабочего дерева', + 'restoration.prepare': 'Подготовить рабочее дерево', + 'branch.worktree': 'Рабочее дерево (на основе {base}): {path}', + 'branch.linked': 'Рабочее дерево: {path}', + 'branch.project': 'Каталог проекта: {path}', + 'branch.missing': 'Рабочее дерево недоступно: {path}', + 'toast.removed': '{name} удалён из этого сеанса', + 'toast.undo': 'Отменить', + 'toast.undoFailed': 'Не удалось отменить', + 'toast.removeFailed': 'Не удалось удалить', + 'toast.refreshFailed': 'Не удалось обновить', + 'toast.copied': 'Ссылка скопирована', + 'toast.copyFailed': 'Не удалось скопировать', + 'toast.branchesFailed': 'Не удалось прочитать ветки', + 'toast.worktreeFailed': 'Не удалось изменить настройку рабочего дерева', + 'toast.worktreeStarted': 'Сеанс уже начат, переключение на рабочее дерево недоступно', + 'toast.restored': 'Рабочее дерево восстановлено', + 'toast.restoreFailed': 'Не удалось восстановить рабочее дерево: {message}', + 'toast.leaveFailed': 'Не удалось перейти в каталог проекта; повторите попытку', + 'toast.markedReady': '{name} готов к ревью', + 'toast.markReadyFailed': 'Не удалось отметить готовность: {message}', + 'toast.merged': '{name} объединён', + 'toast.mergeFailed': 'Не удалось объединить: {message}', + 'toast.mergeStale': 'В {name} есть новые коммиты; проверьте их перед слиянием', + 'toast.pathCopied': 'Путь скопирован', + 'toast.expandFailed': 'Не удалось показать неизменённые строки: {message}', + 'toast.expandStale': 'Файл изменился после чтения изменений; обновите перед разворачиванием', + 'toast.prompted': 'Отправлено: {text}', + 'toast.promptFailed': 'Не удалось отправить сообщение', +} + +/** Portuguese copy with the same keys. */ +export const pt: Record = { + 'tab.title': 'Solicitações de alteração', + 'header.label': 'Solicitações de alteração desta sessão', + 'header.attention': 'As solicitações de alteração desta sessão precisam de atenção', + 'checkout.worktree': 'Árvore de trabalho', + 'checkout.worktreeFrom': 'Árvore de trabalho · a partir de {base}', + 'checkout.project': 'Diretório do projeto', + 'checkout.detached': 'Fora de uma branch', + 'checkout.restore': 'Restaurar', + 'checkout.copyWorktreePath': 'Copiar caminho da árvore de trabalho', + 'checkout.copyProjectPath': 'Copiar caminho do projeto', + 'checkout.none': 'Este diretório não é um repositório Git', + 'checkout.failed': 'Não foi possível ler as informações do Git', + 'changes.title': 'Solicitações de alteração desta sessão · {count}', + 'changes.empty': 'Esta sessão ainda não tem solicitações de alteração', + 'changes.emptyHint': 'Aqui aparecem solicitações vinculadas à sessão: criadas, visualizadas, editadas ou usadas em checkout, ou abertas nas branches atuais ou enviadas', + 'changes.local.title': 'Alterações', + 'changes.local.refs': '{head} → {base}', + 'changes.local.files': '{count} arquivos', + 'changes.local.viewAria': 'Ver alterações desta sessão: {count} arquivos, {added} linhas adicionadas e {deleted} removidas', + 'changes.local.create': 'Criar solicitação de alteração', + 'changes.local.prompt': 'Crie uma solicitação de alteração para as alterações desta sessão', + 'signin.title': 'Entre em {host} para ver o status das solicitações', + 'signin.copy': 'Copiar comando de login', + 'signin.unavailable': 'Não foi possível ler as solicitações em {host}: {message}', + 'refresh': 'Atualizar status', + 'retry': 'Tentar novamente', + 'stream.failed': 'Não foi possível carregar o painel de solicitações de alteração', + 'change.number': '#{number}', + 'change.stateAria': 'Status da solicitação: {state}', + 'change.state.open': 'Aberta', + 'change.state.blocked': 'Precisa de atenção', + 'change.state.draft': 'Rascunho', + 'change.state.merged': 'Mesclada', + 'change.state.closed': 'Fechada', + 'change.state.unknown': 'Desconhecida', + 'change.expand': 'Expandir {name}', + 'change.collapse': 'Recolher {name}', + 'change.ci.none': 'Sem verificações', + 'change.ci.running': 'Verificações em andamento', + 'change.ci.passed': 'Verificações aprovadas', + 'change.ci.failed': '{count} verificações falharam', + 'change.ci.failedUnlisted': 'As verificações falharam', + 'change.checks.failed': '{count} falharam', + 'change.checks.running': '{count} em andamento', + 'change.checks.passed': '{count} aprovadas', + 'change.checks.other': '{count} outras', + 'change.checks.truncated': 'Apenas as primeiras {count} são exibidas', + 'change.check.running': 'Em andamento', + 'change.check.queued': 'Na fila', + 'change.flag.changesRequested': 'Alterações solicitadas', + 'change.flag.conflicting': 'Conflitos', + 'change.flag.draft': 'Rascunho', + 'change.failure.unavailable': 'Não foi possível ler o status da solicitação', + 'change.failure.notFound': 'A solicitação não existe ou não está acessível', + 'change.failure.unauthenticated': 'Entre para ver o status', + 'change.unserved': 'O status de solicitações deste serviço de código ainda não é compatível', + 'change.view': 'Abrir no navegador', + 'change.copy': 'Copiar link da solicitação', + 'change.viewChangesAria': 'Ver alterações de {name}: {added} linhas adicionadas e {deleted} removidas', + 'change.remove': 'Remover desta sessão', + 'change.removeAria': 'Remover {name} desta sessão', + 'change.row.checks': 'Verificações', + 'change.row.review': 'Revisão', + 'change.row.merge': 'Mesclar', + 'change.row.changes': 'Alterações', + 'change.review.approved': 'Aprovada', + 'change.review.changesRequested': 'Alterações solicitadas', + 'change.review.required': 'Revisão necessária', + 'change.review.none': 'Nenhuma revisão solicitada', + 'change.review.threads': '{review} · {count} não resolvidas', + 'change.merge.mergeable': 'Sem conflitos', + 'change.merge.conflicting': 'Conflitos com {base}', + 'change.merge.unknown': 'Verificando conflitos', + 'change.mergedTo': 'Mesclada em {base}', + 'change.markReady': 'Pronta para revisão', + 'change.merge': 'Mesclar', + 'change.mergeTitle': 'Mesclar {name} em {base}', + 'change.mergeMethod.squash': 'Mesclar com squash', + 'change.mergeMethod.merge': 'Criar um commit de merge', + 'change.mergeMethod.rebase': 'Rebase e merge', + 'change.mergeNote.squash': 'Combinar todos os commits em um', + 'change.mergeNote.merge': 'Manter todos os commits e adicionar um commit de merge', + 'change.mergeNote.rebase': 'Reaplicar cada commit sobre {base}', + 'change.mergeConfirm': 'Confirmar merge', + 'change.mergeChanged': 'Há novos commits. Revise-os antes de reabrir o menu de merge', + 'diff.tab': 'Alterações', + 'diff.tabNumbered': 'Alterações em #{number}', + 'diff.scope.branch': 'Branch', + 'diff.scope.uncommitted': 'Sem commit', + 'diff.scope.unstaged': 'Fora da área de stage', + 'diff.scope.staged': 'Na área de stage', + 'diff.scopeNote.branch': 'Comparação com {base}, incluindo alterações sem commit', + 'diff.scopeNote.uncommitted': 'Comparação com HEAD, incluindo arquivos não rastreados', + 'diff.scopeNote.unstaged': 'Comparação com o índice, incluindo arquivos não rastreados', + 'diff.scopeNote.staged': 'Comparação do índice com HEAD', + 'diff.allFiles': 'Todos os {count} arquivos', + 'diff.refresh': 'Atualizar alterações', + 'diff.expandAll': 'Expandir todos os arquivos', + 'diff.collapseAll': 'Recolher todos os arquivos', + 'diff.split': 'Mostrar lado a lado', + 'diff.unified': 'Mostrar em uma coluna', + 'diff.splitAria': 'Lado a lado', + 'diff.wrap': 'Quebrar linhas longas', + 'diff.nowrap': 'Não quebrar linhas longas', + 'diff.wrapAria': 'Quebrar linhas', + 'diff.unchanged': '{count} linhas sem alterações', + 'diff.unchangedRest': 'Demais linhas sem alterações', + 'diff.newFile': 'Novo arquivo', + 'diff.deletedFile': 'Excluído', + 'diff.renamed': 'Renomeado de {from}', + 'diff.renamedPath': '{from} → {path}', + 'diff.badgeUncommitted': 'Sem commit', + 'diff.binary': 'Arquivo binário; não é possível mostrar as alterações', + 'diff.binaryLabel': 'Binário', + 'diff.largeCollapsed': 'Arquivo grande: {count} linhas alteradas recolhidas', + 'diff.fileTooLarge': 'Este arquivo é grande demais para ser exibido', + 'diff.error': 'Não foi possível ler as alterações', + 'diff.tooLarge': 'Estas alterações são grandes demais para serem exibidas aqui', + 'diff.viewInBrowser': 'Ver no navegador', + 'diff.noForkPoint': 'Não foi encontrado o ponto de ramificação desta branch; escolha outro escopo', + 'diff.empty': 'Sem alterações', + 'diff.omitted': 'Outros {count} arquivos não rastreados não são exibidos', + 'diff.stale': 'A branch local tem {count} commits não enviados; são exibidas as alterações da solicitação', + 'diff.moreActions': 'Mais ações para {name}', + 'diff.copyPath': 'Copiar caminho', + 'diff.openFileInBrowser': 'Ver arquivo no navegador', + 'diff.openFileInSidebar': 'Abrir o arquivo inteiro na barra lateral', + 'time.now': 'agora mesmo', + 'time.minutes': 'há {n} min', + 'time.hours': 'há {n} h', + 'time.days': 'há {n} dias', + 'time.months': 'há {n} meses', + 'time.years': 'há {n} anos', + 'time.finished': '{state} · {time}', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes} min {seconds} s', + 'duration.hours': '{hours} h {minutes} min', + 'worktree.label': 'Árvore de trabalho', + 'worktree.hint': 'Execute esta sessão em sua própria árvore de trabalho: o envio cria uma branch a partir da escolhida, sem que alterações e commits afetem o diretório do projeto', + 'worktree.blocked.not-git': 'Este espaço de trabalho não é um repositório Git e não pode usar uma árvore de trabalho', + 'worktree.blocked.outside-writable-root': 'Os dados Git estão fora dos diretórios graváveis, como quando o espaço já é uma árvore de trabalho; ainda não é possível usar outra árvore', + 'worktree.blocked.read-only': 'Uma sessão somente leitura não pode usar uma árvore de trabalho', + 'worktree.base': 'A partir de {branch}', + 'worktree.baseHead': 'A partir do HEAD atual', + 'worktree.baseMenu': 'Branch base', + 'worktree.currentBranch': 'Atual', + 'creation.title': 'Criando a árvore de trabalho', + 'creation.failed': 'Não foi possível criar a árvore de trabalho', + 'creation.prepare': 'Criar a branch', + 'creation.checkout': 'Fazer checkout dos arquivos', + 'creation.details': 'Mais detalhes', + 'creation.branch': 'Branch', + 'creation.base': 'Base', + 'creation.head': 'HEAD atual', + 'creation.error': 'Razão', + 'creation.skip': 'Continuar sem árvore de trabalho', + 'creation.cancel': 'Cancelar', + 'creation.stopped': 'Criação da árvore de trabalho interrompida', + 'creation.retry': 'Tentar novamente', + 'creation.waiting': 'A mensagem será enviada após a criação da árvore de trabalho', + 'missing.title': 'A árvore de trabalho está indisponível', + 'missing.unknown': 'A restauração está incompleta e não é possível inspecionar a branch {branch}. Corrija o erro e tente novamente ou continue no projeto', + 'missing.kept': 'A árvore da branch {branch} pode ser restaurada no local original. Alterações sem commit anteriores à remoção não podem ser recuperadas', + 'missing.recreated': 'A branch {branch} também foi removida. Ela será recriada a partir do commit inicial da árvore, sem commits posteriores ou alterações sem commit', + 'missing.restore': 'Restaurar árvore de trabalho', + 'missing.leave': 'Continuar no projeto', + 'missing.waiting': 'A mensagem será enviada após restaurar a árvore ou continuar no projeto', + 'restoration.title': 'Restaurando a árvore de trabalho', + 'restoration.prepare': 'Preparar a árvore de trabalho', + 'branch.worktree': 'Árvore de trabalho (a partir de {base}): {path}', + 'branch.linked': 'Árvore de trabalho: {path}', + 'branch.project': 'Diretório do projeto: {path}', + 'branch.missing': 'Árvore de trabalho indisponível: {path}', + 'toast.removed': '{name} foi removido desta sessão', + 'toast.undo': 'Desfazer', + 'toast.undoFailed': 'Não foi possível desfazer', + 'toast.removeFailed': 'Não foi possível remover', + 'toast.refreshFailed': 'Falha ao atualizar', + 'toast.copied': 'Link copiado', + 'toast.copyFailed': 'Falha na cópia', + 'toast.branchesFailed': 'Não foi possível ler as branches', + 'toast.worktreeFailed': 'Não foi possível alterar a opção da árvore de trabalho', + 'toast.worktreeStarted': 'Esta sessão já começou e não pode mais usar uma árvore de trabalho', + 'toast.restored': 'Árvore de trabalho restaurada', + 'toast.restoreFailed': 'Não foi possível restaurar a árvore de trabalho: {message}', + 'toast.leaveFailed': 'Não foi possível mudar para o diretório do projeto; tente novamente', + 'toast.markedReady': '{name} está pronta para revisão', + 'toast.markReadyFailed': 'Não foi possível marcar como pronta: {message}', + 'toast.merged': '{name} foi mesclada', + 'toast.mergeFailed': 'Não foi possível mesclar: {message}', + 'toast.mergeStale': '{name} tem novos commits; revise-os antes de mesclar', + 'toast.pathCopied': 'Caminho copiado', + 'toast.expandFailed': 'Não foi possível mostrar as linhas sem alterações: {message}', + 'toast.expandStale': 'O arquivo mudou após a leitura das alterações; atualize antes de expandir', + 'toast.prompted': 'Enviado: {text}', + 'toast.promptFailed': 'Não foi possível enviar a mensagem', +} diff --git a/packages/experimental/client-ui-forge/src/client/mount.ts b/packages/experimental/client-ui-forge/src/client/mount.ts index 6f0710fecf..2b858617a7 100644 --- a/packages/experimental/client-ui-forge/src/client/mount.ts +++ b/packages/experimental/client-ui-forge/src/client/mount.ts @@ -22,7 +22,7 @@ import { ChangesTabTitle } from './ChangesTabTitle.tsx' import { ForgeTab, type ForgeTabInjected } from './ForgeTab.tsx' import { ForgeTabTitle } from './ForgeTabTitle.tsx' import { HeaderButton, type HeaderButtonInjected } from './HeaderButton.tsx' -import { en, NS, zh, type ForgePanelKey } from './locales.ts' +import { de, en, es, fr, ja, NS, pt, ru, zh, type ForgePanelKey } from './locales.ts' import { createNoticeSource, createPanelSource, createVisibilitySource, type PanelSource, type VisibilitySource, } from './source.ts' @@ -55,7 +55,7 @@ interface SessionStores { } function registerUi(ctx: Context): void { - ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'forge-panel: dictionaries') + ctx.effect(() => ctx.locale.register(NS, { zh, en, ja, es, fr, de, ru, pt }), 'forge-panel: dictionaries') const t = ctx.locale.bind(NS) const notices = createNoticeSource() const stores = new Map() diff --git a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts index 40e902f05a..a6f61dba6e 100644 --- a/packages/experimental/client-ui-forge/tests/mount.client.spec.ts +++ b/packages/experimental/client-ui-forge/tests/mount.client.spec.ts @@ -361,6 +361,8 @@ describe('mountForgePanel Session stores', () => { f.ctx.locale.addLanguage({ id: 'de', label: 'Deutsch', fallback: 'en' }) f.ctx.locale.setLocale('de') expect(locale.getSnapshot()).toBe('de') + expect(f.ctx.locale.bind(NS)('header.label')).toBe('Änderungsanfragen dieser Sitzung') + expect(f.ctx.locale.bind(NS)('change.mergeTitle', { name: '#7', base: 'main' })).toBe('#7 in main zusammenführen') expect(changed).toHaveBeenCalled() unsubscribe() f.sessions.scope.mockReturnValue(undefined) From 8a5cd2fb638c87a5efce4fca4affc6ad5f943bbf Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 26 Oct 2026 19:34:55 +0800 Subject: [PATCH 32/32] test(forge): align confined reference and recommendation fixtures --- THIRD_PARTY_NOTICES.md | 1 + apps/web/tests/forge-panel.e2e.ts | 18 ++++++++++++++---- .../creation-recommendations/session.v4.jsonl | 2 +- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 1295efff31..cfac97016e 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -134,6 +134,7 @@ External packages installed for runtime use or embedded in distributed Host or b | [`semver`](https://github.com/npm/node-semver) | ISC | | [`serve-static`](https://github.com/expressjs/serve-static) | MIT | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | +| [`shell-quote`](http://github.com/ljharb/shell-quote) | MIT | | [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 | diff --git a/apps/web/tests/forge-panel.e2e.ts b/apps/web/tests/forge-panel.e2e.ts index 1592275e4f..2e537da246 100644 --- a/apps/web/tests/forge-panel.e2e.ts +++ b/apps/web/tests/forge-panel.e2e.ts @@ -224,7 +224,7 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' // The borrowed scenario records one model call, so index 1 appends the reply to the follow-up. if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] })) if (scenario === 'reference') { - const command = `PATH=${JSON.stringify(`${root}:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200` + const command = `PATH=${JSON.stringify(`.fixture-bin:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200` const call = { type: 'tool-call', id: 'view-pr', name: 'bash', arguments: JSON.stringify({ command, description: 'View PR metadata' }) } await writeFile(override, JSON.stringify({ patches: [ { at: 0, entry: { kind: 'chunks', chunks: [ @@ -257,6 +257,13 @@ async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' await git(project, 'remote', 'add', 'origin', 'https://github.com/octo/demo.git') await git(project, 'branch', 'release') await git(project, 'commit', '--quiet', '--allow-empty', '-m', 'main only') + if (scenario === 'reference') { + // The model's confined command must own the CLI double's writable call log. + const modelBin = join(project, '.fixture-bin') + await mkdir(modelBin) + await writeFile(join(modelBin, 'gh'), GH) + await chmod(join(modelBin, 'gh'), 0o755) + } const browser = await chromium.launch() resources.browser = browser @@ -319,9 +326,13 @@ function worktreeRecords(events: readonly SessionEvent[]): unknown[] { } it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omits its URL', async () => { - const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference') + const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference') const { settled } = await sendPrompt(page, scaffold) const sessionId = await settled + const events = await readPersistedEvents(scaffold, sessionId) + const toolOutput = events.flatMap(event => event.type === 'tool/result' + ? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n') + expect(toolOutput).toContain('Add the pull request panel') const header = page.locator('[data-forge-header]') await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理') await header.click() @@ -330,12 +341,11 @@ it.skipIf(MODE === 'record')('automatically shows a numbered PR when gh JSON omi await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'), await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE) - const events = await readPersistedEvents(scaffold, sessionId) expect(events.flatMap((event) => { const record = pluginRecordOf(event) return record?.type === 'plugin:forge-session/binding' ? [record.data] : [] })).toEqual([{ action: 'reference', url: PULL }]) - const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) + const calls = (await readFile(join(project, '.fixture-bin', 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[]) expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url']) expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) diff --git a/snapshots/web/creation-recommendations/session.v4.jsonl b/snapshots/web/creation-recommendations/session.v4.jsonl index fc93d34fd5..5c8bcd8f81 100644 --- a/snapshots/web/creation-recommendations/session.v4.jsonl +++ b/snapshots/web/creation-recommendations/session.v4.jsonl @@ -16,7 +16,7 @@ {"type":"session/title","data":{"title":"I wish I had a","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:6}}"},"stream":[{"type":"chunk","time":1792674450282,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}},{"type":"chunk","time":1792674450282,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}}},{"type":"chunk","time":1792674450283,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_creation_skill","name":"skill","arguments":"{\"name\":\"personalized-pages\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-personalized-recommendations\",\"title\":{\"en\":\"Personalized recommendations\",\"de\":\"Personalisierte Empfehlungen\",\"es\":\"Recomendaciones personalizadas\",\"fr\":\"Recommandations personnalisées\",\"ja\":\"パーソナライズされたおすすめ\",\"pt\":\"Recomendações personalizadas\",\"ru\":\"Персональные рекомендации\",\"zh\":\"个性化推荐\"},\"description\":{\"en\":\"Shows recommendation cards below the answer for what you want from DSH: when you want a page or small tool you can keep, it proposes a new personalized page, built in the background after you click Create; when you ask how to do something in this app or say a capability is missing, it recommends the built-in plugin that provides it, turned on when you click Enable.\",\"de\":\"Zeigt für Ihre individuellen Anforderungen an DSH Empfehlungskarten unter der Antwort an: Wenn Sie eine Seite oder ein kleines Tool zur dauerhaften Nutzung möchten, wird eine neue personalisierte Seite vorgeschlagen, die nach einem Klick auf Erstellen im Hintergrund erstellt wird; wenn Sie fragen, wie etwas in dieser App geht, oder sagen, dass eine Funktion fehlt, wird das integrierte Plugin empfohlen, das sie bereitstellt, und nach einem Klick auf Aktivieren eingeschaltet.\",\"es\":\"Muestra tarjetas de recomendación debajo de la respuesta para tus necesidades personalizadas en DSH: cuando quieres una página o una pequeña herramienta para conservar, propone una nueva página personalizada, que se genera en segundo plano al hacer clic en Crear; cuando preguntas cómo hacer algo en esta aplicación o dices que falta una función, recomienda el complemento integrado que la ofrece, que se activa al hacer clic en Habilitar.\",\"fr\":\"Affiche des cartes de recommandation sous la réponse pour vos besoins personnalisés dans DSH : lorsque vous voulez une page ou un petit outil à conserver, propose une nouvelle page personnalisée, générée en arrière-plan après un clic sur Créer ; lorsque vous demandez comment faire quelque chose dans cette application ou signalez qu’une fonctionnalité manque, recommande le plugin intégré qui la fournit, activé lorsque vous cliquez sur Activer.\",\"ja\":\"DSH に対するあなた個人のニーズに合わせて、回答の下におすすめカードを表示します。長く使えるページや小さなツールが欲しいときは新しいパーソナルページを提案し、「作成」をクリックするとバックグラウンドで生成します。このアプリで何かをする方法を尋ねたときや機能が足りないと言ったときは、それを提供する内蔵プラグインを推薦し、「有効にする」をクリックすると有効になります。\",\"pt\":\"Mostra cartões de recomendação abaixo da resposta para suas necessidades personalizadas no DSH: quando você quer uma página ou uma pequena ferramenta para manter, propõe uma nova página personalizada, gerada em segundo plano depois que você clica em Criar; quando você pergunta como fazer algo neste aplicativo ou diz que falta um recurso, recomenda o plug-in integrado que o oferece, ativado quando você clica em Ativar.\",\"ru\":\"Показывает карточки рекомендаций под ответом с учетом ваших индивидуальных потребностей в DSH: когда вам нужна страница или небольшой инструмент для постоянного использования, предлагает новую персональную страницу, которая создается в фоне после нажатия «Создать»; когда вы спрашиваете, как что-то сделать в этом приложении, или говорите, что не хватает какой-то возможности, рекомендует встроенный плагин, который ее предоставляет, и включает его после нажатия «Включить».\",\"zh\":\"针对你对 DSH 的个性化需求,在回答下方展示推荐卡片:想要能长期使用的页面或小工具时,提议新增个性化页面,点击创建后在后台生成;问到本应用怎么做某件事或缺少某项能力时,推荐对应的内置插件,点击启用后开启。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-agent-team-profile\",\"title\":{\"en\":\"Agent teams\",\"de\":\"Agententeams\",\"es\":\"Equipos de agentes\",\"fr\":\"Équipes d'agents\",\"ja\":\"エージェントチーム\",\"pt\":\"Equipes de agentes\",\"ru\":\"Команды агентов\",\"zh\":\"智能体团队\"},\"description\":{\"en\":\"Lets the agent you talk to split a job among named teammates, track it on a shared task board, and report back with their results.\",\"de\":\"Ermöglicht dem Agenten, mit dem Sie sprechen, eine Aufgabe auf benannte Teammitglieder aufzuteilen, sie auf einem gemeinsamen Aufgabenboard zu verfolgen und Ihnen mit deren Ergebnissen zu berichten.\",\"es\":\"Permite que el agente con el que habla reparta un trabajo entre compañeros con nombre, lo siga en un tablero de tareas compartido y le informe con sus resultados.\",\"fr\":\"Permet à l'agent avec lequel vous parlez de répartir un travail entre des coéquipiers nommés, de le suivre sur un tableau de tâches partagé et de vous rendre compte de leurs résultats.\",\"ja\":\"対話しているエージェントが、仕事を名前付きのチームメイトに分担させ、共有タスクボードで進捗を追跡し、チームメイトの結果をまとめて報告します。\",\"pt\":\"Permite que o agente com quem você conversa divida um trabalho entre colegas de equipe nomeados, acompanhe-o em um quadro de tarefas compartilhado e informe você com os resultados deles.\",\"ru\":\"Позволяет агенту, с которым вы общаетесь, распределить работу между именованными участниками команды, отслеживать её на общей доске задач и отчитываться перед вами об их результатах.\",\"zh\":\"让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"de\":\"Entwicklertools\",\"es\":\"Herramientas de desarrollador\",\"fr\":\"Outils de développement\",\"ja\":\"開発者ツール\",\"pt\":\"Ferramentas de desenvolvedor\",\"ru\":\"Инструменты разработчика\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"For troubleshooting: view what DSH recorded for a session in Session log, inspect per-turn and per-step statistics in the Statistics tab, and debug the DSH backend in Chrome DevTools.\",\"de\":\"Zur Fehlersuche: Sehen Sie in „Sitzungsprotokoll“, was DSH für eine Sitzung aufgezeichnet hat, prüfen Sie im Tab „Statistik“ Kontext, Dauer, Cache und Werkzeugkosten pro Runde und Schritt und debuggen Sie das DSH-Backend in Chrome DevTools.\",\"es\":\"Para solucionar problemas: vea en «Registro de sesión» lo que DSH registró de una sesión, consulte el contexto, los tiempos, la caché y el coste de herramientas por turno y paso en la pestaña «Estadísticas», y depure el backend de DSH en Chrome DevTools.\",\"fr\":\"Pour le dépannage : consultez dans « Journal de session » ce que DSH a enregistré pour une session, examinez le contexte, les durées, le cache et le coût des outils par tour et étape dans l’onglet « Statistiques », et déboguez le backend de DSH dans Chrome DevTools.\",\"ja\":\"トラブルシューティング用です。「セッションログ」で DSH がセッションについて記録した内容を確認し、「統計」タブでターン/ステップごとのコンテキスト、時間、キャッシュ、ツールコストを確認し、Chrome DevTools で DSH バックエンドをデバッグできます。\",\"pt\":\"Para solução de problemas: veja em «Registro da sessão» o que o DSH registrou de uma sessão, consulte contexto, tempos, cache e custo de ferramentas por turno e etapa na aba «Estatísticas» e depure o backend do DSH no Chrome DevTools.\",\"ru\":\"Для диагностики: просматривайте в «Журнале сеанса», что DSH записал для сеанса, смотрите контекст, время, кэш и стоимость инструментов по ходам и шагам на вкладке «Статистика» и отлаживайте бэкенд DSH в Chrome DevTools.\",\"zh\":\"用于排查问题:在「会话数据诊断」中查看 DSH 为会话记录的内容,在「统计」页签查看每轮/每步的上下文、耗时、缓存和工具成本,并在 Chrome DevTools 中调试 DSH 后端。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"de\":\"Spracheingabe\",\"es\":\"Entrada de voz\",\"fr\":\"Saisie vocale\",\"ja\":\"音声入力\",\"pt\":\"Entrada de voz\",\"ru\":\"Голосовой ввод\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"de\":\"Transkribieren Sie Aufnahmen lokal mit SenseVoice; Für die erste Verwendung müssen Abhängigkeiten installiert werden.\",\"es\":\"Transcribe grabaciones localmente con SenseVoice; El primer uso requiere instalar dependencias.\",\"fr\":\"Transcrivez les enregistrements localement avec SenseVoice ; la première utilisation nécessite l'installation de dépendances.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"pt\":\"Transcreva gravações localmente com SenseVoice; o primeiro uso requer a instalação de dependências.\",\"ru\":\"Транскрибируйте записи локально с помощью SenseVoice; при первом использовании требуется установка зависимостей.\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"de\":\"Automatische Autorisierungsüberprüfung\",\"es\":\"Revisión de autorización automática\",\"fr\":\"Examen des autorisations automatiques\",\"ja\":\"自動認可レビュー\",\"pt\":\"Revisão de autorização automática\",\"ru\":\"Проверка автоматической авторизации\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Adds an Auto review access mode: before each tool call, a model checks it against what you asked for, runs allowed calls at once, and holds denied calls for your approval.\",\"de\":\"Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung.\",\"es\":\"Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación.\",\"fr\":\"Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation.\",\"ja\":\"「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。\",\"pt\":\"Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação.\",\"ru\":\"Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения.\",\"zh\":\"提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"de\":\"Maschinelle Übersetzung der Argumentation\",\"es\":\"Traducción automática de razonamiento\",\"fr\":\"Traduction automatique du raisonnement\",\"ja\":\"思考内容の機械翻訳\",\"pt\":\"Tradução automática de raciocínio\",\"ru\":\"Машинный перевод рассуждений\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Translates expanded Think rows into your chosen language as the agent writes them, and View original switches back.\",\"de\":\"Übersetzt aufgeklappte „Denken Sie nach“-Zeilen, während der Agent sie schreibt, in die von Ihnen gewählte Sprache, und „Original ansehen“ wechselt zurück.\",\"es\":\"Traduce las filas “pensar” expandidas al idioma que elija mientras el agente las escribe, y “Ver original” vuelve al texto original.\",\"fr\":\"Traduit les lignes « Pensez » développées dans la langue de votre choix pendant que l'agent les écrit, et « Voir l'original » permet de revenir au texte d'origine.\",\"ja\":\"エージェントが書き出すのと同時に、展開された「思考」行を選択した言語に翻訳します。「原文を表示」で原文に戻せます。\",\"pt\":\"Traduz as linhas “Pense” expandidas para o idioma que você escolher enquanto o agente as escreve, e “Ver original” volta ao texto original.\",\"ru\":\"Переводит развёрнутые строки «Подумайте» на выбранный вами язык по мере того, как агент их пишет, а «Посмотреть оригинал» возвращает исходный текст.\",\"zh\":\"智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"de\":\"DSH-Abzeichen-Fertigkeit\",\"es\":\"Habilidad de insignia DSH\",\"fr\":\"Compétence du badge DSH\",\"ja\":\"DSH バッジスキル\",\"pt\":\"DSH habilidade de distintivo\",\"ru\":\"DSH значок навыка\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"de\":\"Lassen Sie Agenten Dokumente, Pull-Requests und andere Ausgaben mit einem „powered by dsh“-Badge versehen.\",\"es\":\"Permita que los agentes agreguen una insignia \\\"con tecnología de dsh\\\" a documentos, solicitudes de extracción y otros resultados.\",\"fr\":\"Laissez les agents ajouter un badge « propulsé par dsh » aux documents, demandes d'extraction et autres sorties.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"pt\":\"Permita que os agentes adicionem um selo “powered by dsh” a documentos, pull requests e outros resultados.\",\"ru\":\"Позвольте агентам добавлять значок «Powered by dsh» к документам, запросам на включение и другим результатам.\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"de\":\"Ralph-Loops\",\"es\":\"Bucles de Ralph\",\"fr\":\"Boucles Ralph\",\"ja\":\"Ralph ループ\",\"pt\":\"Loops de Ralph\",\"ru\":\"Ральф петли\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Ask for a Ralph loop with a goal, and the agent starts a fresh agent on it round after round until the goal is met, a blocker is reported, or the round limit is reached.\",\"de\":\"Bitten Sie um eine Ralph-Schleife mit einem Ziel, und der Agent startet Runde um Runde einen neuen Agenten darauf, bis das Ziel erreicht ist, ein Hindernis gemeldet wird oder das Rundenlimit erreicht ist.\",\"es\":\"Pida un bucle Ralph con un objetivo, y el agente inicia un agente nuevo sobre él ronda tras ronda hasta que se cumpla el objetivo, se informe de un bloqueo o se alcance el límite de rondas.\",\"fr\":\"Demandez une boucle Ralph avec un objectif, et l'agent lance un nouvel agent dessus, tour après tour, jusqu'à ce que l'objectif soit atteint, qu'un blocage soit signalé ou que la limite de tours soit atteinte.\",\"ja\":\"目標を指定して Ralph ループを依頼すると、エージェントは、目標が達成されるか、阻害要因が報告されるか、ラウンド数の上限に達するまで、ラウンドごとに新しいエージェントを起動してその目標に取り組ませます。\",\"pt\":\"Peça um loop Ralph com um objetivo, e o agente inicia um novo agente nele rodada após rodada até que o objetivo seja atingido, um bloqueio seja informado ou o limite de rodadas seja alcançado.\",\"ru\":\"Попросите запустить цикл Ralph с целью, и агент будет раунд за раундом запускать для неё нового агента, пока цель не будет достигнута, не будет сообщено о препятствии или не будет достигнут лимит раундов.\",\"zh\":\"要求用 Ralph 循环完成一个目标后,智能体会一轮接一轮地启动新的智能体处理它,直到目标达成、报告受阻或达到轮次上限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"de\":\"Konversationsfolgende Titel\",\"es\":\"Títulos que siguen la conversación\",\"fr\":\"Titres de suivi de conversation\",\"ja\":\"会話に追従するタイトル\",\"pt\":\"Títulos que acompanham conversas\",\"ru\":\"Заголовки, следующие за разговором\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Writes a session's title from all of your messages and updates it when the main task changes, instead of only from the first message.\",\"de\":\"Erstellt den Titel einer Sitzung aus all Ihren Nachrichten und aktualisiert ihn, wenn sich die Hauptaufgabe ändert, statt ihn nur aus der ersten Nachricht zu bilden.\",\"es\":\"Escribe el título de una sesión a partir de todos sus mensajes y lo actualiza cuando cambia la tarea principal, en lugar de basarse solo en el primer mensaje.\",\"fr\":\"Rédige le titre d'une session à partir de tous vos messages et le met à jour lorsque la tâche principale change, au lieu de se baser uniquement sur le premier message.\",\"ja\":\"最初のメッセージだけでなく、あなたのすべてのメッセージからセッションのタイトルを作成し、主なタスクが変わったときに更新します。\",\"pt\":\"Escreve o título de uma sessão a partir de todas as suas mensagens e o atualiza quando a tarefa principal muda, em vez de usar apenas a primeira mensagem.\",\"ru\":\"Составляет заголовок сеанса по всем вашим сообщениям и обновляет его при смене основной задачи, а не только по первому сообщению.\",\"zh\":\"根据你的所有消息生成会话标题,并在主要任务变化时更新,而不是只根据第一条消息生成。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"de\":\"Persistente Terminals\",\"es\":\"Terminales persistentes\",\"fr\":\"Terminaux persistants\",\"ja\":\"永続ターミナル\",\"pt\":\"Terminais persistentes\",\"ru\":\"Постоянные терминалы\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Gives the agent terminals whose processes keep running in the background between tool calls, so it can start a dev server or REPL and come back later to read output or type input.\",\"de\":\"Stellt dem Agenten Terminals bereit, deren Prozesse zwischen Tool-Aufrufen im Hintergrund weiterlaufen, sodass er einen Entwicklungsserver oder eine REPL starten und später zurückkehren kann, um Ausgaben zu lesen oder Eingaben zu tippen.\",\"es\":\"Proporciona al agente terminales cuyos procesos siguen ejecutándose en segundo plano entre llamadas a herramientas, de modo que puede iniciar un servidor de desarrollo o un REPL y volver más tarde para leer la salida o escribir entradas.\",\"fr\":\"Fournit à l'agent des terminaux dont les processus continuent de s'exécuter en arrière-plan d'un appel d'outil à l'autre, afin qu'il puisse lancer un serveur de développement ou un REPL et revenir plus tard lire la sortie ou saisir des entrées.\",\"ja\":\"エージェントにターミナルを提供します。プロセスはツール呼び出し間もバックグラウンドで実行され続けるため、エージェントは開発サーバーや REPL を起動し、後から出力を読んだり入力したりできます。\",\"pt\":\"Fornece ao agente terminais cujos processos continuam em execução em segundo plano entre chamadas de ferramentas, para que ele possa iniciar um servidor de desenvolvimento ou um REPL e voltar depois para ler a saída ou digitar entradas.\",\"ru\":\"Предоставляет агенту терминалы, процессы которых продолжают работать в фоне между вызовами инструментов, поэтому агент может запустить сервер разработки или REPL и позже вернуться, чтобы прочитать вывод или ввести данные.\",\"zh\":\"为智能体提供终端,进程在多次工具调用之间持续在后台运行,因此它可以启动开发服务器或 REPL,稍后再读取输出或继续输入。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"de\":\"Sitzungssuche\",\"es\":\"búsqueda de sesión\",\"fr\":\"Recherche de sessions\",\"ja\":\"セッション検索\",\"pt\":\"Pesquisa de sessão\",\"ru\":\"Поиск сеанса\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Ask about earlier work, such as “How did we fix the pool leak last week?”, and the agent searches your earlier sessions in the same workspace to answer.\",\"de\":\"Fragen Sie nach früherer Arbeit, etwa „Wie haben wir letzte Woche das Pool-Leck behoben?“, und der Agent durchsucht Ihre früheren Sitzungen aus demselben Arbeitsverzeichnis, um zu antworten.\",\"es\":\"Pregunte por trabajo anterior, como “¿Cómo arreglamos la fuga del pool la semana pasada?”, y el agente busca en sus sesiones anteriores del mismo directorio de trabajo para responder.\",\"fr\":\"Posez une question sur un travail antérieur, par exemple « Comment avons-nous corrigé la fuite du pool la semaine dernière ? », et l'agent recherche dans vos sessions antérieures du même répertoire de travail pour répondre.\",\"ja\":\"「先週プールのリークをどう直した?」のように以前の作業について尋ねると、エージェントは同じ作業ディレクトリの過去のセッションを検索して回答します。\",\"pt\":\"Pergunte sobre trabalhos anteriores, como “Como corrigimos o vazamento do pool na semana passada?”, e o agente pesquisa suas sessões anteriores do mesmo diretório de trabalho para responder.\",\"ru\":\"Спросите о прошлой работе, например «Как мы исправили утечку в пуле на прошлой неделе?», и агент выполнит поиск по вашим прошлым сеансам из того же рабочего каталога, чтобы ответить.\",\"zh\":\"询问以往的工作,例如“上周我们是怎么修好连接池泄漏的?”,智能体会搜索同一工作区的历史会话来回答。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"de\":\"Git-Arbeitsbäume\",\"es\":\"árboles de trabajo de git\",\"fr\":\"Arbres de travail Git\",\"ja\":\"Git ワークツリー\",\"pt\":\"Árvores de trabalho Git\",\"ru\":\"Рабочие деревья Git\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Ask for work on a new branch, and the agent creates a separate Git worktree and continues there, leaving your current checkout as it is.\",\"de\":\"Bitten Sie um Arbeit auf einem neuen Branch, und der Agent erstellt einen separaten Git-Worktree und arbeitet dort weiter, während Ihr aktueller Checkout unverändert bleibt.\",\"es\":\"Pida trabajar en una rama nueva, y el agente crea un worktree de Git independiente y continúa allí, dejando su checkout actual tal como está.\",\"fr\":\"Demandez à travailler sur une nouvelle branche, et l'agent crée un worktree Git distinct et y poursuit le travail, en laissant votre copie de travail actuelle telle quelle.\",\"ja\":\"新しいブランチでの作業を依頼すると、エージェントは別の Git ワークツリーを作成してそこで作業を続け、現在のチェックアウトはそのまま残します。\",\"pt\":\"Peça para trabalhar em um novo branch, e o agente cria um worktree do Git separado e continua lá, deixando seu checkout atual como está.\",\"ru\":\"Попросите поработать в новой ветке, и агент создаст отдельное рабочее дерево Git и продолжит работу там, оставив вашу текущую рабочую копию как есть.\",\"zh\":\"要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"en\":\"Computer use\",\"de\":\"Computernutzung\",\"es\":\"Uso de la computadora\",\"fr\":\"Utilisation de l'ordinateur\",\"ja\":\"Computer Use\",\"pt\":\"Uso do computador\",\"ru\":\"Использование компьютера\",\"zh\":\"Computer Use\"},\"description\":{\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"de\":\"Unterstützt derzeit macOS; Die Windows-Unterstützung befindet sich in der Entwicklung. Lädt die Computernutzung für bestehende und neue PTC-Sitzungen. Wechseln Sie in den PTC-Modus und erteilen Sie die Berechtigungen für Barrierefreiheit und Bildschirmaufzeichnung, um ihn zu verwenden.\",\"es\":\"Actualmente es compatible con macOS; El soporte para Windows está en desarrollo. Carga el uso de la computadora para sesiones PTC nuevas y existentes. Cambie al modo PTC y otorgue permisos de Accesibilidad y Grabación de pantalla para usarlo.\",\"fr\":\"Prend actuellement en charge macOS ; La prise en charge de Windows est en cours de développement. Charge l'utilisation de l'ordinateur pour les sessions PTC existantes et nouvelles. Passez en mode PTC et accordez les autorisations d’accessibilité et d’enregistrement d’écran pour l’utiliser.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"pt\":\"Atualmente suporta macOS; O suporte Windows está em desenvolvimento. Carrega o uso do computador para sessões PTC novas e existentes. Mude para o modo PTC e conceda permissões de acessibilidade e gravação de tela para usá-lo.\",\"ru\":\"В настоящее время поддерживается macOS; Поддержка Windows находится в разработке. Загружает использование компьютера для существующих и новых сеансов PTC. Переключитесь в режим PTC и предоставьте разрешения на доступность и запись экрана для его использования.\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"de\":\"Benutzerdefinierte Plugin-Indizes\",\"es\":\"Índices de complementos personalizados\",\"fr\":\"Index de plugins personnalisés\",\"ja\":\"カスタムプラグインインデックス\",\"pt\":\"Índices de plug-ins personalizados\",\"ru\":\"Пользовательские индексы плагинов\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins, where you browse and install plugins from plugin indexes you add.\",\"de\":\"Fügt unter „Plugins“ den Tab „Entdecken“ hinzu, in dem Sie Plugins aus von Ihnen hinzugefügten Plugin-Indizes durchsuchen und installieren.\",\"es\":\"Añade la pestaña “Descubrir” a “Complementos”, donde puede explorar e instalar complementos de los índices de complementos que agregue.\",\"fr\":\"Ajoute à « Plugins » un onglet « Découvrez » où vous parcourez et installez des plugins provenant des index de plugins que vous ajoutez.\",\"ja\":\"「プラグイン」に「見つける」タブを追加し、追加したプラグインインデックスからプラグインを閲覧・インストールできるようにします。\",\"pt\":\"Adiciona a guia “Descubra” a “Plug-ins”, onde você navega e instala plug-ins dos índices de plug-ins que adiciona.\",\"ru\":\"Добавляет в «Плагины» вкладку «Откройте для себя», где можно просматривать и устанавливать плагины из добавленных вами индексов плагинов.\",\"zh\":\"在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"de\":\"Claude Code-Subagent\",\"en\":\"Claude Code subagent\",\"es\":\"Subagente Claude Code\",\"fr\":\"Sous-agent Claude Code\",\"ja\":\"Claude Code サブエージェント\",\"pt\":\"Claude Code subagente\",\"ru\":\"Субагент Claude Code\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Claude Code, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"es\":\"Delegue una tarea a Claude Code utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Claude Code à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Claude Code usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Claude Code, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"de\":\"Codex-Subagent\",\"en\":\"Codex subagent\",\"es\":\"Subagente Codex\",\"fr\":\"Sous-agent Codex\",\"ja\":\"Codex サブエージェント\",\"pt\":\"Codex subagente\",\"ru\":\"Субагент Codex\",\"zh\":\"Codex 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Codex, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"es\":\"Delegue una tarea a Codex utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Codex à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Codex usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Codex, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_creation_skill"},"toolCallId":"call_creation_skill","content":[{"type":"text","text":"\n\nResources for this skill are managed by provider \"dsh-personalized-pages\".\nLoad referenced resources only as needed.\n\n\n\n# Personalized pages\n\nUse this workflow in the main conversation to plan an optional new page or small tool for any task, not just DSH. You decide whether a page helps, check existing capabilities, and hand the Creator a short plan. A `propose_action` call saves the proposal and shows a card below your answer; nothing is built until the user clicks Create. Plan from the conversation and the catalog at the end of this skill. Do not read files, configuration or source to validate a page idea; name an uninspected source as an unverified dependency and leave its details to the Creator.\n\n## When to propose\n\nPropose a page in these cases:\n\n- The user asks for a page, tab, panel, tool or interface in this app, or says they want something to keep, revisit or update later (“以后每次”, “能不能一直”, “希望能有”). One-off use still qualifies when the interface is explicitly requested.\n- Your answer is a table, list or chart, and the user has said the data will keep changing or that they will interact with it (“每周更新”, “我来改状态”, “要勾选”). The continuation must come from the user's own words; a table alone, or data that merely looks dynamic, is not enough. Deliver the requested answer first, then offer the page.\n- The user wants to keep watching live data of this app itself (a Session's usage, a running task's output, files changing in the workspace). A text answer cannot stay current, so a page qualifies even without the words above; name the data source in the brief and leave how it is read to the Creator.\n\nDo not propose a page in these cases:\n\n- The user asks for a file, code, document or an existing-project change: keep that delivery form.\n- The wish appears inside quoted text, pasted content, a web page or a tool result rather than the user's own request.\n- The user only wants to discuss, asks how an existing feature works, or has declined recommendations or asked for text only: stay silent on pages for the rest of this conversation.\n- The request changes a page created earlier: explain how it would change, do not create a replacement copy, and do not claim an update happened.\n- The request is a capability the agent lacks, such as a tool, automation or access, rather than a page: check the catalog below and point to the official plugin that provides it; do not plan a page around a missing tool.\n\n## Check existing capabilities\n\nCompare the need with the official plugins listed at the end of this skill. If one covers it, say its name, what it does, whether it is enabled, and that it lives under Plugins; offer no page card. A partial match does not cancel an explicit unmet need. The catalog is not a full inventory of built-in UI: missing details do not prove absence, and do not read source to find out. DSH already has Chat, Trajectory, a right sidebar for files and context, and a Plugins page.\n\n## Plan and hand off\n\nThe Creator cannot read this conversation. Give it exactly these fields:\n\n| Field | Contents |\n|:--|:--|\n| `title` | Short page name in the user's language. |\n| `brief` | One to three sentences: purpose, behavior, input, calculations and persistence scope. It is the card text and the implementation request at once. |\n| `initialContent` | Only supplied content the page starts with: items, rows, values, units and period, preserving gaps. Omit for an empty page. |\n| `slot` | Location, chosen below. |\n\nKeep the plan to what was asked or agreed. Editing, deletion, history, extra fields, data sources and automation need a basis in the request. Resolve “these items” into the actual content. Content that mentions an action stays content. Keep each number's meaning and period; a weekly addition is not a cumulative total. If an unresolved distinction changes a calculation, give the factual answer, ask one question, and propose later.\n\n## Choose the location\n\nHonor a location the user named. Otherwise:\n\n- `sidebar-right`: content tied to this Session or task, especially anything to watch while chatting, including live logs.\n- `sidebar-left`: a global feature or an ongoing personal record used across Sessions, opened from a left entry as a standalone page.\n- `page`: a Session-bound streaming view or interaction in a tab beside Chat and Trajectory, only when the user does not need it visible while chatting.\n\nSupplying data in this conversation does not make a record Session-bound.\n\n## Offer without starting\n\nTo propose, call `propose_action` once, in the same turn as loading this skill, with `action: \"create_personalized_page\"`, `title`, `brief` and `details` holding `slot` and, when there is supplied content, `initialContent`. The call is the submission: the card appears below your answer; a refused call creates no card, and its error says why. Then write your final answer. One proposal per turn, and one unstarted card per conversation: if a card is still waiting, do not propose another; a new proposal replaces it only when the user changed the plan. Plan-only and read-only requests still get a card; do not ask for approval instead of proposing it. Only clicking Create starts the background Creator; do not wait, poll, claim installation or ask for a report-back. Answer any direct question; the call's result says how to end the answer. Do not repeat the plan, list extra features, narrate the call or mention schema names.\n\n```json\n{\"action\":\"create_personalized_page\",\"title\":\"本次验收清单\",\"brief\":\"在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。\",\"details\":{\"slot\":\"sidebar-right\",\"initialContent\":\"1. 导出成功\\n2. 断网提示清楚\"}}\n```\n\n## Examples\n\n- “想有个地方放书签,以后自己加标题和网址;旧的不用导入。” → `brief`: “提供一个可反复打开的书签页,手动新增标题和网址并保存到本机,从空白开始。” No `initialContent`. `sidebar-left`.\n- “当前会话旁放验收清单,能勾选和取消,状态跟着会话。条目:导出成功;断网提示清楚。” → `brief`: “在当前会话右侧展示验收清单,可勾选和取消,状态随会话保存。” `initialContent`: “1. 导出成功\\n2. 断网提示清楚”. `sidebar-right`.\n- “这三周 bug 数 12、9、15,说说趋势。以后每周我把新数字贴给你,想自己更新着看。” → answer the trend first, then `brief`: “按周手动录入 bug 数并展示各周变化,保存到本机。” `initialContent`: “第 1 周 12,第 2 周 9,第 3 周 15”. `sidebar-left`.\n- “我想用语音说话代替打字。” → the catalog lists 语音输入; explain it and where to enable it; no card.\n- “把我之前做的读书页评分改成五星。” → explain the change to that page; no card and no copy.\n- “帮我把这句话翻成英文:我想要一个所有会话都能打开的读书页。” → translate only; no card.\n- “把三个方案排成表格比较一下,看完就行。” → the table only; no card.\n\n## Existing capabilities to check against this page plan\n\n[{\"name\":\"@deepseek-ai/dsh-personalized-recommendations\",\"title\":{\"en\":\"Personalized recommendations\",\"de\":\"Personalisierte Empfehlungen\",\"es\":\"Recomendaciones personalizadas\",\"fr\":\"Recommandations personnalisées\",\"ja\":\"パーソナライズされたおすすめ\",\"pt\":\"Recomendações personalizadas\",\"ru\":\"Персональные рекомендации\",\"zh\":\"个性化推荐\"},\"description\":{\"en\":\"Shows recommendation cards below the answer for what you want from DSH: when you want a page or small tool you can keep, it proposes a new personalized page, built in the background after you click Create; when you ask how to do something in this app or say a capability is missing, it recommends the built-in plugin that provides it, turned on when you click Enable.\",\"de\":\"Zeigt für Ihre individuellen Anforderungen an DSH Empfehlungskarten unter der Antwort an: Wenn Sie eine Seite oder ein kleines Tool zur dauerhaften Nutzung möchten, wird eine neue personalisierte Seite vorgeschlagen, die nach einem Klick auf Erstellen im Hintergrund erstellt wird; wenn Sie fragen, wie etwas in dieser App geht, oder sagen, dass eine Funktion fehlt, wird das integrierte Plugin empfohlen, das sie bereitstellt, und nach einem Klick auf Aktivieren eingeschaltet.\",\"es\":\"Muestra tarjetas de recomendación debajo de la respuesta para tus necesidades personalizadas en DSH: cuando quieres una página o una pequeña herramienta para conservar, propone una nueva página personalizada, que se genera en segundo plano al hacer clic en Crear; cuando preguntas cómo hacer algo en esta aplicación o dices que falta una función, recomienda el complemento integrado que la ofrece, que se activa al hacer clic en Habilitar.\",\"fr\":\"Affiche des cartes de recommandation sous la réponse pour vos besoins personnalisés dans DSH : lorsque vous voulez une page ou un petit outil à conserver, propose une nouvelle page personnalisée, générée en arrière-plan après un clic sur Créer ; lorsque vous demandez comment faire quelque chose dans cette application ou signalez qu’une fonctionnalité manque, recommande le plugin intégré qui la fournit, activé lorsque vous cliquez sur Activer.\",\"ja\":\"DSH に対するあなた個人のニーズに合わせて、回答の下におすすめカードを表示します。長く使えるページや小さなツールが欲しいときは新しいパーソナルページを提案し、「作成」をクリックするとバックグラウンドで生成します。このアプリで何かをする方法を尋ねたときや機能が足りないと言ったときは、それを提供する内蔵プラグインを推薦し、「有効にする」をクリックすると有効になります。\",\"pt\":\"Mostra cartões de recomendação abaixo da resposta para suas necessidades personalizadas no DSH: quando você quer uma página ou uma pequena ferramenta para manter, propõe uma nova página personalizada, gerada em segundo plano depois que você clica em Criar; quando você pergunta como fazer algo neste aplicativo ou diz que falta um recurso, recomenda o plug-in integrado que o oferece, ativado quando você clica em Ativar.\",\"ru\":\"Показывает карточки рекомендаций под ответом с учетом ваших индивидуальных потребностей в DSH: когда вам нужна страница или небольшой инструмент для постоянного использования, предлагает новую персональную страницу, которая создается в фоне после нажатия «Создать»; когда вы спрашиваете, как что-то сделать в этом приложении, или говорите, что не хватает какой-то возможности, рекомендует встроенный плагин, который ее предоставляет, и включает его после нажатия «Включить».\",\"zh\":\"针对你对 DSH 的个性化需求,在回答下方展示推荐卡片:想要能长期使用的页面或小工具时,提议新增个性化页面,点击创建后在后台生成;问到本应用怎么做某件事或缺少某项能力时,推荐对应的内置插件,点击启用后开启。\"},\"enabled\":true},{\"name\":\"@deepseek-ai/dsh-experimental-agent-team-profile\",\"title\":{\"en\":\"Agent teams\",\"de\":\"Agententeams\",\"es\":\"Equipos de agentes\",\"fr\":\"Équipes d'agents\",\"ja\":\"エージェントチーム\",\"pt\":\"Equipes de agentes\",\"ru\":\"Команды агентов\",\"zh\":\"智能体团队\"},\"description\":{\"en\":\"Lets the agent you talk to split a job among named teammates, track it on a shared task board, and report back with their results.\",\"de\":\"Ermöglicht dem Agenten, mit dem Sie sprechen, eine Aufgabe auf benannte Teammitglieder aufzuteilen, sie auf einem gemeinsamen Aufgabenboard zu verfolgen und Ihnen mit deren Ergebnissen zu berichten.\",\"es\":\"Permite que el agente con el que habla reparta un trabajo entre compañeros con nombre, lo siga en un tablero de tareas compartido y le informe con sus resultados.\",\"fr\":\"Permet à l'agent avec lequel vous parlez de répartir un travail entre des coéquipiers nommés, de le suivre sur un tableau de tâches partagé et de vous rendre compte de leurs résultats.\",\"ja\":\"対話しているエージェントが、仕事を名前付きのチームメイトに分担させ、共有タスクボードで進捗を追跡し、チームメイトの結果をまとめて報告します。\",\"pt\":\"Permite que o agente com quem você conversa divida um trabalho entre colegas de equipe nomeados, acompanhe-o em um quadro de tarefas compartilhado e informe você com os resultados deles.\",\"ru\":\"Позволяет агенту, с которым вы общаетесь, распределить работу между именованными участниками команды, отслеживать её на общей доске задач и отчитываться перед вами об их результатах.\",\"zh\":\"让与你对话的智能体把工作分给有名字的队友,在共享任务看板上跟踪进度,并汇总队友的结果向你汇报。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-inspector-profile\",\"title\":{\"en\":\"Developer tools\",\"de\":\"Entwicklertools\",\"es\":\"Herramientas de desarrollador\",\"fr\":\"Outils de développement\",\"ja\":\"開発者ツール\",\"pt\":\"Ferramentas de desenvolvedor\",\"ru\":\"Инструменты разработчика\",\"zh\":\"开发者工具\"},\"description\":{\"en\":\"For troubleshooting: view what DSH recorded for a session in Session log, inspect per-turn and per-step statistics in the Statistics tab, and debug the DSH backend in Chrome DevTools.\",\"de\":\"Zur Fehlersuche: Sehen Sie in „Sitzungsprotokoll“, was DSH für eine Sitzung aufgezeichnet hat, prüfen Sie im Tab „Statistik“ Kontext, Dauer, Cache und Werkzeugkosten pro Runde und Schritt und debuggen Sie das DSH-Backend in Chrome DevTools.\",\"es\":\"Para solucionar problemas: vea en «Registro de sesión» lo que DSH registró de una sesión, consulte el contexto, los tiempos, la caché y el coste de herramientas por turno y paso en la pestaña «Estadísticas», y depure el backend de DSH en Chrome DevTools.\",\"fr\":\"Pour le dépannage : consultez dans « Journal de session » ce que DSH a enregistré pour une session, examinez le contexte, les durées, le cache et le coût des outils par tour et étape dans l’onglet « Statistiques », et déboguez le backend de DSH dans Chrome DevTools.\",\"ja\":\"トラブルシューティング用です。「セッションログ」で DSH がセッションについて記録した内容を確認し、「統計」タブでターン/ステップごとのコンテキスト、時間、キャッシュ、ツールコストを確認し、Chrome DevTools で DSH バックエンドをデバッグできます。\",\"pt\":\"Para solução de problemas: veja em «Registro da sessão» o que o DSH registrou de uma sessão, consulte contexto, tempos, cache e custo de ferramentas por turno e etapa na aba «Estatísticas» e depure o backend do DSH no Chrome DevTools.\",\"ru\":\"Для диагностики: просматривайте в «Журнале сеанса», что DSH записал для сеанса, смотрите контекст, время, кэш и стоимость инструментов по ходам и шагам на вкладке «Статистика» и отлаживайте бэкенд DSH в Chrome DevTools.\",\"zh\":\"用于排查问题:在「会话数据诊断」中查看 DSH 为会话记录的内容,在「统计」页签查看每轮/每步的上下文、耗时、缓存和工具成本,并在 Chrome DevTools 中调试 DSH 后端。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-voice-input-bundle\",\"title\":{\"en\":\"Voice input\",\"de\":\"Spracheingabe\",\"es\":\"Entrada de voz\",\"fr\":\"Saisie vocale\",\"ja\":\"音声入力\",\"pt\":\"Entrada de voz\",\"ru\":\"Голосовой ввод\",\"zh\":\"语音输入\"},\"description\":{\"en\":\"Transcribe recordings locally with SenseVoice; first use requires installing dependencies.\",\"de\":\"Transkribieren Sie Aufnahmen lokal mit SenseVoice; Für die erste Verwendung müssen Abhängigkeiten installiert werden.\",\"es\":\"Transcribe grabaciones localmente con SenseVoice; El primer uso requiere instalar dependencias.\",\"fr\":\"Transcrivez les enregistrements localement avec SenseVoice ; la première utilisation nécessite l'installation de dépendances.\",\"ja\":\"ローカルの SenseVoice で録音を文字に起こします。初回利用時には依存関係のインストールが必要です。\",\"pt\":\"Transcreva gravações localmente com SenseVoice; o primeiro uso requer a instalação de dependências.\",\"ru\":\"Транскрибируйте записи локально с помощью SenseVoice; при первом использовании требуется установка зависимостей.\",\"zh\":\"在本机使用 SenseVoice 转写录音,首次使用需安装依赖。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-auto-review\",\"title\":{\"en\":\"Auto authorization review\",\"de\":\"Automatische Autorisierungsüberprüfung\",\"es\":\"Revisión de autorización automática\",\"fr\":\"Examen des autorisations automatiques\",\"ja\":\"自動認可レビュー\",\"pt\":\"Revisão de autorização automática\",\"ru\":\"Проверка автоматической авторизации\",\"zh\":\"自动授权审查\"},\"description\":{\"en\":\"Adds an Auto review access mode: before each tool call, a model checks it against what you asked for, runs allowed calls at once, and holds denied calls for your approval.\",\"de\":\"Fügt den Zugriffsmodus „Auto review“ hinzu: Vor jedem Tool-Aufruf prüft ein Modell den Aufruf anhand Ihrer Anforderung; zugelassene Aufrufe laufen sofort, abgelehnte Aufrufe warten auf Ihre Genehmigung.\",\"es\":\"Añade el modo de acceso “Auto review”: antes de cada llamada a una herramienta, un modelo comprueba la llamada frente a lo que usted pidió; las llamadas permitidas se ejecutan de inmediato y las denegadas esperan su aprobación.\",\"fr\":\"Ajoute le mode d'accès « Auto review » : avant chaque appel d'outil, un modèle vérifie l'appel par rapport à ce que vous avez demandé ; les appels autorisés s'exécutent immédiatement et les appels refusés attendent votre approbation.\",\"ja\":\"「自動認可レビュー」アクセスモードを追加します。各ツール呼び出しの前に、モデルがその呼び出しをあなたの依頼内容と照らし合わせて確認し、許可された呼び出しは即座に実行され、拒否された呼び出しはあなたの承認を待ちます。\",\"pt\":\"Adiciona o modo de acesso “Auto review”: antes de cada chamada de ferramenta, um modelo verifica a chamada em relação ao que você pediu; as chamadas permitidas são executadas imediatamente e as negadas aguardam sua aprovação.\",\"ru\":\"Добавляет режим доступа «Auto review»: перед каждым вызовом инструмента модель сверяет вызов с вашей просьбой; разрешённые вызовы выполняются сразу, а отклонённые ждут вашего одобрения.\",\"zh\":\"提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-cot-translation-bundle\",\"title\":{\"en\":\"Machine translation of reasoning\",\"de\":\"Maschinelle Übersetzung der Argumentation\",\"es\":\"Traducción automática de razonamiento\",\"fr\":\"Traduction automatique du raisonnement\",\"ja\":\"思考内容の機械翻訳\",\"pt\":\"Tradução automática de raciocínio\",\"ru\":\"Машинный перевод рассуждений\",\"zh\":\"思考过程机器翻译\"},\"description\":{\"en\":\"Translates expanded Think rows into your chosen language as the agent writes them, and View original switches back.\",\"de\":\"Übersetzt aufgeklappte „Denken Sie nach“-Zeilen, während der Agent sie schreibt, in die von Ihnen gewählte Sprache, und „Original ansehen“ wechselt zurück.\",\"es\":\"Traduce las filas “pensar” expandidas al idioma que elija mientras el agente las escribe, y “Ver original” vuelve al texto original.\",\"fr\":\"Traduit les lignes « Pensez » développées dans la langue de votre choix pendant que l'agent les écrit, et « Voir l'original » permet de revenir au texte d'origine.\",\"ja\":\"エージェントが書き出すのと同時に、展開された「思考」行を選択した言語に翻訳します。「原文を表示」で原文に戻せます。\",\"pt\":\"Traduz as linhas “Pense” expandidas para o idioma que você escolher enquanto o agente as escreve, e “Ver original” volta ao texto original.\",\"ru\":\"Переводит развёрнутые строки «Подумайте» на выбранный вами язык по мере того, как агент их пишет, а «Посмотреть оригинал» возвращает исходный текст.\",\"zh\":\"智能体输出时,把展开的“思考”行翻译成你选择的语言,点击“查看原文”可切换回原文。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-badge-skill-bundle\",\"title\":{\"en\":\"DSH badge skill\",\"de\":\"DSH-Abzeichen-Fertigkeit\",\"es\":\"Habilidad de insignia DSH\",\"fr\":\"Compétence du badge DSH\",\"ja\":\"DSH バッジスキル\",\"pt\":\"DSH habilidade de distintivo\",\"ru\":\"DSH значок навыка\",\"zh\":\"DSH 徽章技能\"},\"description\":{\"en\":\"Let agents add a “powered by dsh” badge to documents, pull requests, and other outputs.\",\"de\":\"Lassen Sie Agenten Dokumente, Pull-Requests und andere Ausgaben mit einem „powered by dsh“-Badge versehen.\",\"es\":\"Permita que los agentes agreguen una insignia \\\"con tecnología de dsh\\\" a documentos, solicitudes de extracción y otros resultados.\",\"fr\":\"Laissez les agents ajouter un badge « propulsé par dsh » aux documents, demandes d'extraction et autres sorties.\",\"ja\":\"エージェントが文書や PR などの成果物に「powered by dsh」バッジを追加できます。\",\"pt\":\"Permita que os agentes adicionem um selo “powered by dsh” a documentos, pull requests e outros resultados.\",\"ru\":\"Позвольте агентам добавлять значок «Powered by dsh» к документам, запросам на включение и другим результатам.\",\"zh\":\"让 Agent 为文档、PR 等产出添加「powered by dsh」徽章。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-ralph-bundle\",\"title\":{\"en\":\"Ralph loops\",\"de\":\"Ralph-Loops\",\"es\":\"Bucles de Ralph\",\"fr\":\"Boucles Ralph\",\"ja\":\"Ralph ループ\",\"pt\":\"Loops de Ralph\",\"ru\":\"Ральф петли\",\"zh\":\"Ralph 循环\"},\"description\":{\"en\":\"Ask for a Ralph loop with a goal, and the agent starts a fresh agent on it round after round until the goal is met, a blocker is reported, or the round limit is reached.\",\"de\":\"Bitten Sie um eine Ralph-Schleife mit einem Ziel, und der Agent startet Runde um Runde einen neuen Agenten darauf, bis das Ziel erreicht ist, ein Hindernis gemeldet wird oder das Rundenlimit erreicht ist.\",\"es\":\"Pida un bucle Ralph con un objetivo, y el agente inicia un agente nuevo sobre él ronda tras ronda hasta que se cumpla el objetivo, se informe de un bloqueo o se alcance el límite de rondas.\",\"fr\":\"Demandez une boucle Ralph avec un objectif, et l'agent lance un nouvel agent dessus, tour après tour, jusqu'à ce que l'objectif soit atteint, qu'un blocage soit signalé ou que la limite de tours soit atteinte.\",\"ja\":\"目標を指定して Ralph ループを依頼すると、エージェントは、目標が達成されるか、阻害要因が報告されるか、ラウンド数の上限に達するまで、ラウンドごとに新しいエージェントを起動してその目標に取り組ませます。\",\"pt\":\"Peça um loop Ralph com um objetivo, e o agente inicia um novo agente nele rodada após rodada até que o objetivo seja atingido, um bloqueio seja informado ou o limite de rodadas seja alcançado.\",\"ru\":\"Попросите запустить цикл Ralph с целью, и агент будет раунд за раундом запускать для неё нового агента, пока цель не будет достигнута, не будет сообщено о препятствии или не будет достигнут лимит раундов.\",\"zh\":\"要求用 Ralph 循环完成一个目标后,智能体会一轮接一轮地启动新的智能体处理它,直到目标达成、报告受阻或达到轮次上限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-titles-bundle\",\"title\":{\"en\":\"Conversation-following titles\",\"de\":\"Konversationsfolgende Titel\",\"es\":\"Títulos que siguen la conversación\",\"fr\":\"Titres de suivi de conversation\",\"ja\":\"会話に追従するタイトル\",\"pt\":\"Títulos que acompanham conversas\",\"ru\":\"Заголовки, следующие за разговором\",\"zh\":\"随对话更新的标题\"},\"description\":{\"en\":\"Writes a session's title from all of your messages and updates it when the main task changes, instead of only from the first message.\",\"de\":\"Erstellt den Titel einer Sitzung aus all Ihren Nachrichten und aktualisiert ihn, wenn sich die Hauptaufgabe ändert, statt ihn nur aus der ersten Nachricht zu bilden.\",\"es\":\"Escribe el título de una sesión a partir de todos sus mensajes y lo actualiza cuando cambia la tarea principal, en lugar de basarse solo en el primer mensaje.\",\"fr\":\"Rédige le titre d'une session à partir de tous vos messages et le met à jour lorsque la tâche principale change, au lieu de se baser uniquement sur le premier message.\",\"ja\":\"最初のメッセージだけでなく、あなたのすべてのメッセージからセッションのタイトルを作成し、主なタスクが変わったときに更新します。\",\"pt\":\"Escreve o título de uma sessão a partir de todas as suas mensagens e o atualiza quando a tarefa principal muda, em vez de usar apenas a primeira mensagem.\",\"ru\":\"Составляет заголовок сеанса по всем вашим сообщениям и обновляет его при смене основной задачи, а не только по первому сообщению.\",\"zh\":\"根据你的所有消息生成会话标题,并在主要任务变化时更新,而不是只根据第一条消息生成。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-terminal-bundle\",\"title\":{\"en\":\"Persistent terminals\",\"de\":\"Persistente Terminals\",\"es\":\"Terminales persistentes\",\"fr\":\"Terminaux persistants\",\"ja\":\"永続ターミナル\",\"pt\":\"Terminais persistentes\",\"ru\":\"Постоянные терминалы\",\"zh\":\"持久终端\"},\"description\":{\"en\":\"Gives the agent terminals whose processes keep running in the background between tool calls, so it can start a dev server or REPL and come back later to read output or type input.\",\"de\":\"Stellt dem Agenten Terminals bereit, deren Prozesse zwischen Tool-Aufrufen im Hintergrund weiterlaufen, sodass er einen Entwicklungsserver oder eine REPL starten und später zurückkehren kann, um Ausgaben zu lesen oder Eingaben zu tippen.\",\"es\":\"Proporciona al agente terminales cuyos procesos siguen ejecutándose en segundo plano entre llamadas a herramientas, de modo que puede iniciar un servidor de desarrollo o un REPL y volver más tarde para leer la salida o escribir entradas.\",\"fr\":\"Fournit à l'agent des terminaux dont les processus continuent de s'exécuter en arrière-plan d'un appel d'outil à l'autre, afin qu'il puisse lancer un serveur de développement ou un REPL et revenir plus tard lire la sortie ou saisir des entrées.\",\"ja\":\"エージェントにターミナルを提供します。プロセスはツール呼び出し間もバックグラウンドで実行され続けるため、エージェントは開発サーバーや REPL を起動し、後から出力を読んだり入力したりできます。\",\"pt\":\"Fornece ao agente terminais cujos processos continuam em execução em segundo plano entre chamadas de ferramentas, para que ele possa iniciar um servidor de desenvolvimento ou um REPL e voltar depois para ler a saída ou digitar entradas.\",\"ru\":\"Предоставляет агенту терминалы, процессы которых продолжают работать в фоне между вызовами инструментов, поэтому агент может запустить сервер разработки или REPL и позже вернуться, чтобы прочитать вывод или ввести данные.\",\"zh\":\"为智能体提供终端,进程在多次工具调用之间持续在后台运行,因此它可以启动开发服务器或 REPL,稍后再读取输出或继续输入。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-session-search\",\"title\":{\"en\":\"Session search\",\"de\":\"Sitzungssuche\",\"es\":\"búsqueda de sesión\",\"fr\":\"Recherche de sessions\",\"ja\":\"セッション検索\",\"pt\":\"Pesquisa de sessão\",\"ru\":\"Поиск сеанса\",\"zh\":\"会话搜索\"},\"description\":{\"en\":\"Ask about earlier work, such as “How did we fix the pool leak last week?”, and the agent searches your earlier sessions in the same workspace to answer.\",\"de\":\"Fragen Sie nach früherer Arbeit, etwa „Wie haben wir letzte Woche das Pool-Leck behoben?“, und der Agent durchsucht Ihre früheren Sitzungen aus demselben Arbeitsverzeichnis, um zu antworten.\",\"es\":\"Pregunte por trabajo anterior, como “¿Cómo arreglamos la fuga del pool la semana pasada?”, y el agente busca en sus sesiones anteriores del mismo directorio de trabajo para responder.\",\"fr\":\"Posez une question sur un travail antérieur, par exemple « Comment avons-nous corrigé la fuite du pool la semaine dernière ? », et l'agent recherche dans vos sessions antérieures du même répertoire de travail pour répondre.\",\"ja\":\"「先週プールのリークをどう直した?」のように以前の作業について尋ねると、エージェントは同じ作業ディレクトリの過去のセッションを検索して回答します。\",\"pt\":\"Pergunte sobre trabalhos anteriores, como “Como corrigimos o vazamento do pool na semana passada?”, e o agente pesquisa suas sessões anteriores do mesmo diretório de trabalho para responder.\",\"ru\":\"Спросите о прошлой работе, например «Как мы исправили утечку в пуле на прошлой неделе?», и агент выполнит поиск по вашим прошлым сеансам из того же рабочего каталога, чтобы ответить.\",\"zh\":\"询问以往的工作,例如“上周我们是怎么修好连接池泄漏的?”,智能体会搜索同一工作区的历史会话来回答。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-tool-worktree\",\"title\":{\"en\":\"Git worktrees\",\"de\":\"Git-Arbeitsbäume\",\"es\":\"árboles de trabajo de git\",\"fr\":\"Arbres de travail Git\",\"ja\":\"Git ワークツリー\",\"pt\":\"Árvores de trabalho Git\",\"ru\":\"Рабочие деревья Git\",\"zh\":\"Git 工作树\"},\"description\":{\"en\":\"Ask for work on a new branch, and the agent creates a separate Git worktree and continues there, leaving your current checkout as it is.\",\"de\":\"Bitten Sie um Arbeit auf einem neuen Branch, und der Agent erstellt einen separaten Git-Worktree und arbeitet dort weiter, während Ihr aktueller Checkout unverändert bleibt.\",\"es\":\"Pida trabajar en una rama nueva, y el agente crea un worktree de Git independiente y continúa allí, dejando su checkout actual tal como está.\",\"fr\":\"Demandez à travailler sur une nouvelle branche, et l'agent crée un worktree Git distinct et y poursuit le travail, en laissant votre copie de travail actuelle telle quelle.\",\"ja\":\"新しいブランチでの作業を依頼すると、エージェントは別の Git ワークツリーを作成してそこで作業を続け、現在のチェックアウトはそのまま残します。\",\"pt\":\"Peça para trabalhar em um novo branch, e o agente cria um worktree do Git separado e continua lá, deixando seu checkout atual como está.\",\"ru\":\"Попросите поработать в новой ветке, и агент создаст отдельное рабочее дерево Git и продолжит работу там, оставив вашу текущую рабочую копию как есть.\",\"zh\":\"要求在新分支上工作时,智能体会创建单独的 Git 工作树并在其中继续,你当前的工作树保持原样。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-cua-bundle\",\"title\":{\"en\":\"Computer use\",\"de\":\"Computernutzung\",\"es\":\"Uso de la computadora\",\"fr\":\"Utilisation de l'ordinateur\",\"ja\":\"Computer Use\",\"pt\":\"Uso do computador\",\"ru\":\"Использование компьютера\",\"zh\":\"Computer Use\"},\"description\":{\"en\":\"Currently supports macOS; Windows support is in development. Loads computer use for existing and new PTC sessions. Switch to PTC mode and grant Accessibility and Screen Recording permissions to use it.\",\"de\":\"Unterstützt derzeit macOS; Die Windows-Unterstützung befindet sich in der Entwicklung. Lädt die Computernutzung für bestehende und neue PTC-Sitzungen. Wechseln Sie in den PTC-Modus und erteilen Sie die Berechtigungen für Barrierefreiheit und Bildschirmaufzeichnung, um ihn zu verwenden.\",\"es\":\"Actualmente es compatible con macOS; El soporte para Windows está en desarrollo. Carga el uso de la computadora para sesiones PTC nuevas y existentes. Cambie al modo PTC y otorgue permisos de Accesibilidad y Grabación de pantalla para usarlo.\",\"fr\":\"Prend actuellement en charge macOS ; La prise en charge de Windows est en cours de développement. Charge l'utilisation de l'ordinateur pour les sessions PTC existantes et nouvelles. Passez en mode PTC et accordez les autorisations d’accessibilité et d’enregistrement d’écran pour l’utiliser.\",\"ja\":\"現在は macOS のみ対応し、Windows 対応は開発中です。既存および新規の PTC セッションに Computer Use を読み込みます。PTC モードに切り替え、アクセシビリティと画面収録の権限を許可して使用してください。\",\"pt\":\"Atualmente suporta macOS; O suporte Windows está em desenvolvimento. Carrega o uso do computador para sessões PTC novas e existentes. Mude para o modo PTC e conceda permissões de acessibilidade e gravação de tela para usá-lo.\",\"ru\":\"В настоящее время поддерживается macOS; Поддержка Windows находится в разработке. Загружает использование компьютера для существующих и новых сеансов PTC. Переключитесь в режим PTC и предоставьте разрешения на доступность и запись экрана для его использования.\",\"zh\":\"目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-plugin-market-bundle\",\"title\":{\"en\":\"Custom plugin indexes\",\"de\":\"Benutzerdefinierte Plugin-Indizes\",\"es\":\"Índices de complementos personalizados\",\"fr\":\"Index de plugins personnalisés\",\"ja\":\"カスタムプラグインインデックス\",\"pt\":\"Índices de plug-ins personalizados\",\"ru\":\"Пользовательские индексы плагинов\",\"zh\":\"自定义插件索引\"},\"description\":{\"en\":\"Adds a Discover tab to Plugins, where you browse and install plugins from plugin indexes you add.\",\"de\":\"Fügt unter „Plugins“ den Tab „Entdecken“ hinzu, in dem Sie Plugins aus von Ihnen hinzugefügten Plugin-Indizes durchsuchen und installieren.\",\"es\":\"Añade la pestaña “Descubrir” a “Complementos”, donde puede explorar e instalar complementos de los índices de complementos que agregue.\",\"fr\":\"Ajoute à « Plugins » un onglet « Découvrez » où vous parcourez et installez des plugins provenant des index de plugins que vous ajoutez.\",\"ja\":\"「プラグイン」に「見つける」タブを追加し、追加したプラグインインデックスからプラグインを閲覧・インストールできるようにします。\",\"pt\":\"Adiciona a guia “Descubra” a “Plug-ins”, onde você navega e instala plug-ins dos índices de plug-ins que adiciona.\",\"ru\":\"Добавляет в «Плагины» вкладку «Откройте для себя», где можно просматривать и устанавливать плагины из добавленных вами индексов плагинов.\",\"zh\":\"在插件页添加“发现”标签页,从你添加的插件索引中浏览和安装插件。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-experimental-forge-bundle\",\"title\":{\"en\":\"Change requests & worktrees\",\"de\":\"Änderungsanfragen und Arbeitsbäume\",\"es\":\"Solicitudes de cambio y árboles de trabajo\",\"fr\":\"Demandes de modification et arbres de travail\",\"ja\":\"変更リクエストとワークツリー\",\"pt\":\"Solicitações de alteração e árvores de trabalho\",\"ru\":\"Запросы на изменение и рабочие деревья\",\"zh\":\"变更请求与工作树\"},\"description\":{\"en\":\"Track each Session's change requests with their checks and reviews, and start new Sessions in a Git worktree; uses the gh CLI for GitHub.\",\"de\":\"Verfolgen Sie die Änderungsanfragen jeder Sitzung mit ihren Prüfungen und Reviews und starten Sie neue Sitzungen in einem Git-Arbeitsbaum; nutzt die gh CLI für GitHub.\",\"es\":\"Siga las solicitudes de cambio de cada sesión con sus comprobaciones y revisiones, e inicie nuevas sesiones en un árbol de trabajo de Git; usa la CLI gh para GitHub.\",\"fr\":\"Suivez les demandes de modification de chaque session avec leurs vérifications et revues, et démarrez de nouvelles sessions dans un arbre de travail Git ; utilise la CLI gh pour GitHub.\",\"ja\":\"各セッションの変更リクエストとそのチェックやレビューを追跡し、新しいセッションを Git ワークツリーで開始できます。GitHub には gh CLI を使用します。\",\"pt\":\"Acompanhe as solicitações de alteração de cada sessão com suas verificações e revisões e inicie novas sessões em uma árvore de trabalho Git; usa a CLI gh para o GitHub.\",\"ru\":\"Отслеживайте запросы на изменение каждого сеанса вместе с их проверками и ревью и начинайте новые сеансы в рабочем дереве Git; для GitHub используется gh CLI.\",\"zh\":\"跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-claude-code\",\"title\":{\"de\":\"Claude Code-Subagent\",\"en\":\"Claude Code subagent\",\"es\":\"Subagente Claude Code\",\"fr\":\"Sous-agent Claude Code\",\"ja\":\"Claude Code サブエージェント\",\"pt\":\"Claude Code subagente\",\"ru\":\"Субагент Claude Code\",\"zh\":\"Claude Code 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Claude Code, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Claude Code using its native authentication and settings.\",\"es\":\"Delegue una tarea a Claude Code utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Claude Code à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Claude Code のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Claude Code usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Claude Code, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Claude Code 原生认证和设置委派单个任务。\"},\"enabled\":false},{\"name\":\"@deepseek-ai/dsh-subagent-codex\",\"title\":{\"de\":\"Codex-Subagent\",\"en\":\"Codex subagent\",\"es\":\"Subagente Codex\",\"fr\":\"Sous-agent Codex\",\"ja\":\"Codex サブエージェント\",\"pt\":\"Codex subagente\",\"ru\":\"Субагент Codex\",\"zh\":\"Codex 子智能体\"},\"description\":{\"de\":\"Delegieren Sie eine Aufgabe an Codex, indem Sie dessen native Authentifizierung und Einstellungen verwenden.\",\"en\":\"Delegate one task to Codex using its native authentication and settings.\",\"es\":\"Delegue una tarea a Codex utilizando su autenticación y configuración nativas.\",\"fr\":\"Déléguez une tâche à Codex à l’aide de son authentification et de ses paramètres natifs.\",\"ja\":\"Codex のネイティブの認証と設定を使用して、単独のタスクを委派します。\",\"pt\":\"Delegue uma tarefa a Codex usando sua autenticação e configurações nativas.\",\"ru\":\"Делегируйте одну задачу Codex, используя его встроенную аутентификацию и настройки.\",\"zh\":\"使用 Codex 原生认证和设置委派单个任务。\"},\"enabled\":false}]\n\n"}],"isError":false,"id":"{{message:7}}"}},"sourceEventSeqs":[16],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":["ask_user_question","bash","create_goal","edit","get_goal","glob","grep","interrupt_agent","job_kill","job_list","job_output","list_agents","list_subagent_models","plugin_manager","present","propose_action","read","read_image","schedule_create","schedule_delete","schedule_list","schedule_update","send_message","skill","subagent","subagent_fork","todo_write","update_goal","web_fetch","web_search","workflow","working_directory","write"]},"reason":"change"}}