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