mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
feat(forge): add the pull request panel and worktree Sessions
The forge panel shows the pull requests a Session created or pushed in a right-sidebar tab, with a Session header button and branch label, and lets a new Session start in its own Git worktree from the New Session options row. The forge bundle ships the panel with the forge registry, the GitHub provider, the Session bindings, and an isolated worktree service. The panel listens to the agent-controller/admission stage. It holds the first message of a Session with a worktree request while it creates and enters the worktree as Agent maintenance, and refuses messages while the worktree the Session works in is gone. A failed or stopped creation returns the message to the composer: the card above the composer shows the failure, sending again retries with a new branch name, and Continue without a worktree withdraws the request. A removed worktree can be checked out again or left for the source checkout. A message that enters without admission still gets its worktree at the first step proposal.
This commit is contained in:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 服务;模型的工具看不到面板的服务。
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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:*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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":["刷新失败,请重试"]}
|
||||
|
||||
@@ -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<string> {
|
||||
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<typeof watchConsole>
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ReleaseRequest> {
|
||||
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<SessionId> }> {
|
||||
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([])
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: cd68131cccc623de
|
||||
zh: 9c977cdc8767be10
|
||||
en: 15ba282f07026c0b
|
||||
zh: 73acd9f55122ee43
|
||||
|
||||
@@ -33,7 +33,9 @@ flowchart LR
|
||||
svc_forge["ctx.forge<br/>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<br/>Session change request bindings"]
|
||||
svc_forgePanel["ctx.forgePanel<br/>Forge panel Remote"]
|
||||
pkg_experimental_worktree["experimental-worktree"]
|
||||
svc_worktrees["ctx.worktrees<br/>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. |
|
||||
|
||||
@@ -35,7 +35,9 @@ flowchart LR
|
||||
svc_forge["ctx.forge<br/>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<br/>Session change request bindings"]
|
||||
svc_forgePanel["ctx.forgePanel<br/>Forge panel Remote"]
|
||||
pkg_experimental_worktree["experimental-worktree"]
|
||||
svc_worktrees["ctx.worktrees<br/>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 的作用域内服务于共享资源工具。 |
|
||||
|
||||
+32
-2
@@ -1010,6 +1010,35 @@ export interface CotTranslationPreferences {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
|
||||
<a id="deepseek-aidsh-experimental-client-ui-forge"></a>
|
||||
|
||||
## `@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
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
|
||||
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -1012,6 +1012,35 @@ export interface CotTranslationPreferences {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-cot-translation -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
|
||||
<a id="deepseek-aidsh-experimental-client-ui-forge"></a>
|
||||
|
||||
## `@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
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-client-ui-forge -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
|
||||
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
<!-- BEGIN GENERATED event-producer-consumer:events -->
|
||||
| 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` |
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<!-- BEGIN GENERATED event-producer-consumer:events -->
|
||||
| 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` |
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: a11f106978c9dce7
|
||||
zh: a11f106978c9dce7
|
||||
/experimental-plugin-record-catalog:
|
||||
en: 90e85bc15ccdeb75
|
||||
zh: 3fd9e6d80b2bc689
|
||||
en: 3df60f36caaca677
|
||||
zh: e587f87d2a0dbc48
|
||||
|
||||
@@ -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. | <code>WorktreeRecord</code> · [`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. | <code>BindingRecord</code> · [`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. | <code>TranslationRequestRecord</code> · [`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. | <code>TranslationResultRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
|
||||
|
||||
@@ -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. | <code>WorktreeRecord</code> · [`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. | <code>BindingRecord</code> · [`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. | <code>TranslationRequestRecord</code> · [`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. | <code>TranslationResultRecord</code> · [`packages/experimental/translator/src/types.ts`](../packages/experimental/translator/src/types.ts) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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
|
||||
|
||||
+334
-2
@@ -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<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>>
|
||||
/** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */
|
||||
findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
/** Report whether the provider can reach `host` with current credentials. */
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
}
|
||||
```
|
||||
|
||||
```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[]
|
||||
}
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -225,6 +425,15 @@ changeRequestUrl(ref: ChangeRequestRef): string
|
||||
*/
|
||||
async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
|
||||
/**
|
||||
* 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<ForgeReadiness>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
<a id="ctxforgepanel--forgepanelcontroller"></a>
|
||||
|
||||
### `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<ForgePanelState>
|
||||
|
||||
/**
|
||||
* 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<ForgeBranches>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
```
|
||||
|
||||
Types: [Session](session.md)
|
||||
|
||||
Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts)
|
||||
|
||||
<a id="ctxforgesession--forgesessionservice"></a>
|
||||
|
||||
### `ctx.forgeSession` — `ForgeSessionService`
|
||||
|
||||
+334
-2
@@ -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<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>>
|
||||
/** Find open change requests whose head is `branch` in `repository`, excluding the default branch and forks. */
|
||||
findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
/** Report whether the provider can reach `host` with current credentials. */
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness>
|
||||
}
|
||||
```
|
||||
|
||||
```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[]
|
||||
}
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -225,6 +425,15 @@ changeRequestUrl(ref: ChangeRequestRef): string
|
||||
*/
|
||||
async findByHead(repository: ForgeRepository, branch: string, signal: AbortSignal): Promise<readonly ChangeRequestRef[]>
|
||||
|
||||
/**
|
||||
* 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<ForgeReadiness>
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
<a id="ctxforgepanel--forgepanelcontroller"></a>
|
||||
|
||||
### `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<ForgePanelState>
|
||||
|
||||
/**
|
||||
* 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<ForgeBranches>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
```
|
||||
|
||||
Types: [Session](session.zh.md)
|
||||
|
||||
Source: [`packages/experimental/client-ui-forge/src/index.ts`](../../packages/experimental/client-ui-forge/src/index.ts)
|
||||
|
||||
<a id="ctxforgesession--forgesessionservice"></a>
|
||||
|
||||
### `ctx.forgeSession` — `ForgeSessionService`
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
en: 53f643c5d30e00b2
|
||||
zh: ba9e52efc62c7656
|
||||
/git-worktrees/request-and-result:
|
||||
en: 8391b091607600eb
|
||||
zh: 6c735a337d60d566
|
||||
en: fbab866bf824d6e0
|
||||
zh: 73d2a1ee350e1434
|
||||
|
||||
@@ -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
|
||||
}
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -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<CreatedWorktree>
|
||||
async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise<CreatedWorktree>
|
||||
|
||||
/**
|
||||
* 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<RestoredWorktree>
|
||||
```
|
||||
|
||||
Types: [Agent](core.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
|
||||
}
|
||||
```
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
@@ -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<CreatedWorktree>
|
||||
async create( agent: Agent, request: CreateWorktreeRequest = {}, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise<CreatedWorktree>
|
||||
|
||||
/**
|
||||
* 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<RestoredWorktree>
|
||||
```
|
||||
|
||||
Types: [Agent](core.zh.md)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 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).
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
`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=<maxBranches>`. 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 |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 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.
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 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
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **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.
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
在插件页启用 [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)中。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 了解实现
|
||||
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
`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=<maxBranches>`。这些命令都不读取工作树。每个命令都在 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 标题栏的分支标签 |
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 深入探索
|
||||
|
||||
- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——变更请求类型与服务参考。
|
||||
- [Session 绑定](../forge-session/README.zh.md)——pull request 如何绑定到 Session。
|
||||
- [Worktree 服务](../worktree/README.zh.md)——worktree 如何创建并进入。
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
间接影响,经由工作目录服务:它关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知。检查、状态、恢复与面板都不会给模型请求增加内容。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
新 worktree 的通知没有影响,因为它属于 Session 的第一次请求,那时还没有任何缓存前缀。“在项目目录继续”之后的通知追加在缓存前缀之后。
|
||||
|
||||
## 已知限制与待办工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- **状态只在面板中轮询**——只有本包为该 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)记录了它的接入位置。
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "PR 面板",
|
||||
"description": "在侧栏标签页中显示会话的 Pull Request,并让新会话可以在工作树中开始。"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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<typeof NS> & InjectFace<Omit<NoticeSource, 'report'>>
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Toast
|
||||
key={`forge-panel-${String(notice.seq)}`}
|
||||
text={t(notice.key, notice.params)}
|
||||
{...notice.tone === 'success' ? { tone: 'success' as const } : { icon: <IconWarningOutlineRegular /> }}
|
||||
{...undo === undefined
|
||||
? {}
|
||||
: {
|
||||
holdMs: UNDO_HOLD_MS,
|
||||
actions: [{ label: t('toast.undo'), onClick: () => { dismiss(); undo() } }],
|
||||
}}
|
||||
onDone={dismiss}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PanelSnapshot> }
|
||||
}
|
||||
|
||||
/** Full props of the header label. */
|
||||
export type BranchChipProps = InjectFace<BranchChipInjected> & PropsLocale<typeof NS>
|
||||
|
||||
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 (
|
||||
<Tooltip label={label.detail} side="bottom">
|
||||
<span className={css.chip} data-forge-branch="" data-missing={label.missing ? '' : undefined}>
|
||||
<IconBranchOutlineRegular size={13} className={css.glyph} />
|
||||
<span className={css.text}>{label.branch}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof NS>['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<BindingOrigin, ForgePanelKey> = {
|
||||
created: 'change.origin.created',
|
||||
discovered: 'change.origin.discovered',
|
||||
tracked: 'change.origin.tracked',
|
||||
}
|
||||
|
||||
const REVIEW: Record<ChangeRequestSnapshot['reviewDecision'], ForgePanelKey> = {
|
||||
'approved': 'change.review.approved',
|
||||
'changes-requested': 'change.review.changesRequested',
|
||||
'review-required': 'change.review.required',
|
||||
'none': 'change.review.none',
|
||||
}
|
||||
|
||||
const MERGE: Record<ChangeRequestSnapshot['mergeability'], ForgePanelKey> = {
|
||||
mergeable: 'change.merge.mergeable',
|
||||
conflicting: 'change.merge.conflicting',
|
||||
unknown: 'change.merge.unknown',
|
||||
}
|
||||
|
||||
const FAILURE: Record<ForgeFailure['code'], ForgePanelKey> = {
|
||||
'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 (
|
||||
<span className={css.diff}>
|
||||
<span className={css.added}>{`+${String(snapshot.additions)}`}</span>
|
||||
<span className={css.deleted}>{`−${String(snapshot.deletions)}`}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Ci({ snapshot, t }: { readonly snapshot: ChangeRequestSnapshot; readonly t: Translate }): ReactNode {
|
||||
const ci = ciSummary(snapshot)
|
||||
switch (ci.kind) {
|
||||
case 'none': return <span className={css.ci}>{t('change.ci.none')}</span>
|
||||
case 'running': return <span className={css.ci}><StateDot state="ongoing" size={11} />{t('change.ci.running')}</span>
|
||||
case 'passed': return <span className={css.ci}><StateDot state="done" size={7} />{t('change.ci.passed')}</span>
|
||||
case 'failed': return (
|
||||
<span className={css.ci} data-tone="error">
|
||||
<StateDot state="error" size={7} />
|
||||
{ci.count > 0 ? t('change.ci.failed', { count: ci.count }) : t('change.ci.failedUnlisted')}
|
||||
</span>
|
||||
)
|
||||
/* 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 <span className={css.summary}><span className={css.ci}><StateDot state="ongoing" size={11} />{t('change.loading')}</span></span>
|
||||
}
|
||||
if (snapshot.state !== 'open') {
|
||||
const ago = snapshot.closedAt === null ? undefined : formatAgo(snapshot.closedAt, now, t)
|
||||
const state = t(stateKey(snapshot))
|
||||
return (
|
||||
<span className={css.summary}>
|
||||
<Diff snapshot={snapshot} />
|
||||
<span className={css.finished} data-tone={snapshot.state}>{ago === undefined ? state : t('time.finished', { state, time: ago })}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
const flag = flagOf(snapshot)
|
||||
return (
|
||||
<span className={css.summary}>
|
||||
<Diff snapshot={snapshot} />
|
||||
<Ci snapshot={snapshot} t={t} />
|
||||
{flag !== undefined && <span className={css.flag} data-flag={flag}>{t(`change.flag.${flag}`)}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function CheckIcon({ check }: { readonly check: ChangeRequestCheck }): ReactNode {
|
||||
switch (check.state) {
|
||||
case 'success': return <IconCheckOutlineRegular size={13} className={css.pass} />
|
||||
case 'failure': return <IconCloseOutlineRegular size={13} className={css.fail} />
|
||||
case 'pending': return <StateDot state="ongoing" size={12} />
|
||||
case 'neutral':
|
||||
case 'skipped':
|
||||
case 'cancelled': return <StateDot state="idle" size={7} />
|
||||
/* v8 ignore next -- closed CheckState union exhaustiveness guard */
|
||||
default: return assertNever(check.state, 'ChangeRequestCheck.state')
|
||||
}
|
||||
}
|
||||
|
||||
const CHECK_RANK: Record<ChangeRequestCheck['state'], number> = {
|
||||
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 <Row icon={<IconChecklistOutlineRegular size={15} />} 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 (
|
||||
<>
|
||||
<button type="button" className={css.item} aria-expanded={open} onClick={() => { setOpen(!open) }}>
|
||||
<IconChecklistOutlineRegular size={15} />
|
||||
<span>{t('change.row.checks')}</span>
|
||||
<span className={css.value} data-tone={counts.failed > 0 ? 'error' : undefined}>{summary}</span>
|
||||
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
{open && (
|
||||
<ul className={css.checks}>
|
||||
{orderedChecks(snapshot.checks).map((check, index) => {
|
||||
const body = (
|
||||
<>
|
||||
<CheckIcon check={check} />
|
||||
<span className={clsx(css.code, css.ellipsis)}>{check.name}</span>
|
||||
<span className={css.value}>{checkTime(check, t)}</span>
|
||||
</>
|
||||
)
|
||||
const url = check.url
|
||||
return (
|
||||
<li key={`${String(index)}:${check.name}`}>
|
||||
{url === null
|
||||
? <div className={css.check}>{body}</div>
|
||||
: <button type="button" className={css.check} onClick={() => { openLink(url) }}>{body}</button>}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
{snapshot.checksTruncated && <li className={css.truncated}>{t('change.checks.truncated', { count: snapshot.checks.length })}</li>}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Row({ icon, label, value, tone }: {
|
||||
readonly icon: ReactNode
|
||||
readonly label: string
|
||||
readonly value: string
|
||||
readonly tone?: 'error' | undefined
|
||||
}): ReactNode {
|
||||
return (
|
||||
<div className={css.item} data-static="">
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
<span className={css.value} data-tone={tone}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={css.details}>
|
||||
<div className={css.linkRow}>
|
||||
<button type="button" className={css.item} onClick={() => { actions.openLink(url) }}>
|
||||
<IconRightUpOutlineRegular size={15} />
|
||||
<span>{t('change.view')}</span>
|
||||
</button>
|
||||
<Tooltip label={t('change.copy')} side="top">
|
||||
<button type="button" className={css.iconButton} aria-label={t('change.copy')} onClick={() => { actions.copyLink(url) }}>
|
||||
<IconCopyOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{snapshot !== null && (
|
||||
<>
|
||||
<Row icon={<IconInfoOutlineRegular size={15} />} label={t('change.row.state')} value={`${t(stateKey(snapshot))} · ${t(ORIGIN[entry.origin])}`} />
|
||||
<Checks snapshot={snapshot} openLink={actions.openLink} t={t} />
|
||||
<Row
|
||||
icon={<IconUsersOutlineRegular size={15} />}
|
||||
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}
|
||||
/>
|
||||
<Row
|
||||
icon={<IconBranchOutlineRegular size={15} />}
|
||||
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) && <div className={css.separator} role="separator" />}
|
||||
{addToChat !== undefined && (
|
||||
<button type="button" className={css.item} onClick={() => { addToChat(url) }}>
|
||||
<IconContextInjectionOutlineRegular size={15} />
|
||||
<span>{t('change.addToChat')}</span>
|
||||
</button>
|
||||
)}
|
||||
{remove !== undefined && (
|
||||
<button type="button" className={css.item} data-tone="error" onClick={() => { remove(entry) }}>
|
||||
<IconTrashOutlineRegular size={15} />
|
||||
<span>{t('change.remove')}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<li className={css.change} data-forge-change={entry.url}>
|
||||
<button type="button" className={css.changeHead} aria-expanded={expanded} onClick={onToggle}>
|
||||
<span className={css.headRow}>
|
||||
<span className={css.glyph} data-tone={toneOf(snapshot)} aria-hidden="true"><IconBranchOutlineRegular size={15} /></span>
|
||||
<span className={css.changeTitle}>{snapshot?.title ?? entry.url.replace(/^https?:\/\//i, '')}</span>
|
||||
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, expanded && css.chevronOpen)} />
|
||||
</span>
|
||||
{(number !== null || snapshot !== null) && (
|
||||
<span className={css.metaRow}>
|
||||
{number !== null && <span className={css.code}>{t('change.number', { number })}</span>}
|
||||
{number !== null && snapshot !== null && <span aria-hidden="true">·</span>}
|
||||
{snapshot !== null && <span className={clsx(css.code, css.ellipsis)}>{snapshot.headBranch}</span>}
|
||||
</span>
|
||||
)}
|
||||
<Summary entry={entry} now={now} t={t} />
|
||||
</button>
|
||||
{notice !== undefined && (
|
||||
<div className={css.notice}>
|
||||
<span>{t(notice)}</span>
|
||||
{entry.ref === null
|
||||
? <Button size="sm" variant="outline" onClick={() => { actions.openLink(url) }}>{t('change.view')}</Button>
|
||||
: <Button size="sm" variant="outline" onClick={actions.retry}>{t('retry')}</Button>}
|
||||
</div>
|
||||
)}
|
||||
{expanded && <Details entry={entry} url={url} actions={actions} t={t} />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PanelSnapshot> }
|
||||
/** 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<PropsRuntime<'sidebar.right.pane.tab'>, 'useTabInfo' | 'inputActions'>
|
||||
& InjectFace<ForgeTabInjected>
|
||||
& PropsLocale<typeof NS>
|
||||
|
||||
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 <div className={css.checkout}>{t('checkout.none')}</div>
|
||||
if (checkout.state === 'failed') {
|
||||
return (
|
||||
<Tooltip label={checkout.message} side="bottom">
|
||||
<div className={css.checkout} tabIndex={0}>{t('checkout.failed')}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
const created = state.worktree.created
|
||||
const worktree = created !== null && created.path === checkout.checkout.root ? created : null
|
||||
const branch = worktree?.branch ?? checkout.checkout.branch
|
||||
return (
|
||||
<Tooltip label={checkout.checkout.root} side="bottom">
|
||||
<div className={css.checkout} tabIndex={0} data-forge-checkout="">
|
||||
<IconBranchOutlineRegular size={14} className={css.checkoutIcon} />
|
||||
<span>{t(worktree === null ? 'checkout.branch' : 'checkout.worktree')}</span>
|
||||
<span className={css.checkoutBranch}>{branch ?? t('checkout.detached')}</span>
|
||||
<span className={css.checkoutNote}>
|
||||
{worktree === null
|
||||
? t('checkout.current')
|
||||
: t('checkout.basedOn', { base: worktree.from ?? worktree.baseCommit.slice(0, 7) })}
|
||||
</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
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 <div className={css.banner} role="status">{t('signin.unavailable', { host, message: readiness.message ?? '' })}</div>
|
||||
}
|
||||
const command = repository.forge === 'github' ? `gh auth login --hostname ${host}` : undefined
|
||||
return (
|
||||
<div className={css.banner} role="status">
|
||||
<span>{t('signin.title', { host })}</span>
|
||||
{command === undefined
|
||||
? readiness.message !== null && <span className={css.bannerDetail}>{readiness.message}</span>
|
||||
: (
|
||||
<>
|
||||
<code className={css.bannerCommand}>{command}</code>
|
||||
<Button size="sm" variant="outline" className={css.bannerAction} onClick={() => { copyText(command) }}>
|
||||
{t('signin.copy')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string | null>(null)
|
||||
useEffect(() => {
|
||||
reportVisible(visible)
|
||||
}, [reportVisible, visible])
|
||||
useEffect(() => () => { reportVisible(false) }, [reportVisible])
|
||||
const state = snapshot.state
|
||||
const disconnected = snapshot.disconnected && (
|
||||
<div className={css.disconnected} role="status">
|
||||
<span>{t('stream.failed')}</span>
|
||||
<Button size="sm" variant="outline" onClick={reconnect}>{t('retry')}</Button>
|
||||
</div>
|
||||
)
|
||||
if (state === null) return <div className={css.centered}>{disconnected || <StateDot state="ongoing" />}</div>
|
||||
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 (
|
||||
<div className={css.root} data-forge-panel="">
|
||||
{disconnected}
|
||||
<Checkout state={state} t={t} />
|
||||
<section className={css.section}>
|
||||
<div className={css.sectionHeader}>
|
||||
<h3 className={css.sectionTitle}>{t('changes.title', { count })}</h3>
|
||||
<Tooltip label={t('refresh')} side="top">
|
||||
<button type="button" className={css.iconButton} aria-label={t('refresh')} onClick={refresh}>
|
||||
<IconRefreshOutlineRegular size={14} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<SignIn state={state} copyText={copyText} t={t} />
|
||||
{count === 0
|
||||
? (
|
||||
<div className={css.empty}>
|
||||
<p className={css.emptyTitle}>{t('changes.empty')}</p>
|
||||
<p className={css.emptyHint}>{t('changes.emptyHint')}</p>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<ul className={css.list}>
|
||||
{state.changeRequests.map(entry => (
|
||||
<ChangeRequestCard
|
||||
key={entry.url}
|
||||
entry={entry}
|
||||
expanded={expanded === entry.url}
|
||||
onToggle={() => { setExpanded(expanded === entry.url ? null : entry.url) }}
|
||||
now={now}
|
||||
actions={actions}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<typeof NS>): ReactNode {
|
||||
return (
|
||||
<>
|
||||
<IconBranchOutlineRegular size={16} className={css.titleIcon} />
|
||||
<span className={css.titleText}>{t('tab.title')}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PanelSnapshot>; readonly visible: HostObservable<boolean> }
|
||||
/**
|
||||
* 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<HeaderButtonInjected> & PropsLocale<typeof NS>
|
||||
|
||||
/**
|
||||
* 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 (
|
||||
<Tooltip label={label} side="bottom">
|
||||
<button
|
||||
type="button"
|
||||
className={css.trigger}
|
||||
aria-label={label}
|
||||
aria-pressed={pressed}
|
||||
data-forge-header=""
|
||||
onClick={() => { toggle(pressed) }}
|
||||
>
|
||||
<IconBranchOutlineRegular size={16} />
|
||||
{attention && <StateDot state="error" size={6} className={css.dot} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PanelSnapshot> }
|
||||
/** 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<string | undefined>
|
||||
/** Move the Session back to the source checkout of its removed worktree. */
|
||||
readonly leave: () => void
|
||||
}
|
||||
|
||||
/** Full props of the card. */
|
||||
export type WorktreeProgressProps = InjectFace<WorktreeProgressInjected> & PropsLocale<typeof NS>
|
||||
|
||||
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 <IconCheckOutlineRegular size={14} className={css.done} />
|
||||
case 'running': return <StateDot state="ongoing" size={14} />
|
||||
case 'failed': return <IconCloseOutlineRegular size={14} className={css.failed} />
|
||||
case 'waiting': return <span className={css.waiting} aria-hidden="true" />
|
||||
/* v8 ignore next -- closed StepState union exhaustiveness guard */
|
||||
default: return assertNever(state, 'StepState')
|
||||
}
|
||||
}
|
||||
|
||||
/** One creation as the card shows it: the running step, or the failed one with its failure text. */
|
||||
interface CreationView {
|
||||
readonly phase: 'prepare' | 'checkout'
|
||||
readonly branch: string | null
|
||||
readonly from: string | null
|
||||
/** Failure text, or null while the creation runs. */
|
||||
readonly message: string | null
|
||||
/** Whether a message waits for the running creation, which can then be stopped. */
|
||||
readonly held: boolean
|
||||
}
|
||||
|
||||
/** The running creation, else the last failed one, or undefined without either. */
|
||||
function creationOf(worktree: ForgePanelWorktree): CreationView | undefined {
|
||||
const { creation, failure } = worktree
|
||||
if (creation !== null) return { ...creation, message: null }
|
||||
return failure === null ? undefined : { ...failure, held: false }
|
||||
}
|
||||
|
||||
function Details({ creation, t }: { readonly creation: CreationView; readonly t: Translate }): ReactNode {
|
||||
return (
|
||||
<dl className={css.details}>
|
||||
{creation.branch !== null && (
|
||||
<>
|
||||
<dt>{t('creation.branch')}</dt>
|
||||
<dd className={css.code}>{creation.branch}</dd>
|
||||
</>
|
||||
)}
|
||||
<dt>{t('creation.base')}</dt>
|
||||
<dd className={css.code}>{creation.from ?? t('creation.head')}</dd>
|
||||
{creation.message !== null && (
|
||||
<>
|
||||
<dt>{t('creation.error')}</dt>
|
||||
<dd className={css.message}>{creation.message}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
|
||||
function CreationCard({ creation, stop, withdraw, t }: {
|
||||
readonly creation: CreationView
|
||||
readonly stop: WorktreeProgressInjected['stop']
|
||||
readonly withdraw: WorktreeProgressInjected['withdraw']
|
||||
readonly t: Translate
|
||||
}): ReactNode {
|
||||
const [open, setOpen] = useState(false)
|
||||
const failed = creation.message !== null
|
||||
const expanded = open || failed
|
||||
return (
|
||||
<div className={css.dock} data-forge-creation={failed ? 'failed' : creation.phase}>
|
||||
<section className={css.card} aria-label={t(failed ? 'creation.failed' : 'creation.title')} aria-busy={!failed}>
|
||||
<div className={css.header}>
|
||||
<IconBranchOutlineRegular size={14} className={css.glyph} />
|
||||
<span className={css.title}>{t(failed ? 'creation.failed' : 'creation.title')}</span>
|
||||
{creation.branch !== null && <span className={css.branch}>{creation.branch}</span>}
|
||||
</div>
|
||||
<ul className={css.steps}>
|
||||
{STEPS.map(step => (
|
||||
<li key={step.phase} className={css.step} data-state={stepState(creation.phase, failed, step.phase)}>
|
||||
<StepIcon state={stepState(creation.phase, failed, step.phase)} />
|
||||
<span>{t(step.label)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{expanded && <Details creation={creation} t={t} />}
|
||||
{failed && <p className={css.note}>{t('creation.retryHint')}</p>}
|
||||
<div className={css.footer}>
|
||||
{!failed && (
|
||||
<button type="button" className={css.disclosure} aria-expanded={open} onClick={() => { setOpen(!open) }}>
|
||||
<IconChevronRightOutlineRegular size={12} className={open ? css.chevronOpen : css.chevron} />
|
||||
{t('creation.details')}
|
||||
</button>
|
||||
)}
|
||||
{failed && (
|
||||
<span className={css.actions}>
|
||||
<Button size="sm" variant="ghost" onClick={withdraw}>{t('creation.skip')}</Button>
|
||||
</span>
|
||||
)}
|
||||
{creation.held && (
|
||||
<span className={css.actions}>
|
||||
<Button size="sm" variant="ghost" icon={<IconCloseOutlineRegular size={14} />} onClick={stop}>{t('creation.cancel')}</Button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MissingCard({ created, missing, restore, leave, t }: {
|
||||
readonly created: ForgeSessionWorktree
|
||||
readonly missing: ForgeMissingWorktree
|
||||
readonly restore: WorktreeProgressInjected['restore']
|
||||
readonly leave: WorktreeProgressInjected['leave']
|
||||
readonly t: Translate
|
||||
}): ReactNode {
|
||||
const [restoring, setRestoring] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const start = (): void => {
|
||||
setRestoring(true)
|
||||
setError(null)
|
||||
void restore().then(
|
||||
(failure) => {
|
||||
setRestoring(false)
|
||||
setError(failure ?? null)
|
||||
},
|
||||
(failure: unknown) => {
|
||||
setRestoring(false)
|
||||
setError(failure instanceof Error ? failure.message : String(failure))
|
||||
},
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={css.dock} data-forge-missing="">
|
||||
<section className={css.card} aria-label={t('missing.title')}>
|
||||
<div className={css.header}>
|
||||
<IconBranchOutlineRegular size={14} className={css.warning} />
|
||||
<span className={css.title}>{t('missing.title')}</span>
|
||||
<span className={css.branch}>{created.branch}</span>
|
||||
</div>
|
||||
<p className={css.note}>{t(missing.branchExists ? 'missing.kept' : 'missing.recreated', { branch: created.branch })}</p>
|
||||
{error !== null && <p className={css.error}>{error}</p>}
|
||||
<div className={css.footer}>
|
||||
<span className={css.actions}>
|
||||
<Button size="sm" variant="primary" disabled={restoring} onClick={start}>{t('missing.restore')}</Button>
|
||||
<Button size="sm" variant="ghost" disabled={restoring} onClick={leave}>{t('missing.leave')}</Button>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the worktree card of a Session: the running creation of its
|
||||
* requested worktree, which can be stopped while a message waits for it; the
|
||||
* last failed creation, after which sending the message again tries again or
|
||||
* withdrawing the request lets it go without a worktree; or the removal of the
|
||||
* worktree the Session works in, with restoring it and returning to the source
|
||||
* checkout.
|
||||
* @param props - panel hook, actions, and copy.
|
||||
* @returns the card, or nothing while no creation runs or failed and the worktree exists.
|
||||
*/
|
||||
export function WorktreeProgress({ usePanel, stop, withdraw, restore, leave, t }: WorktreeProgressProps): ReactNode {
|
||||
const worktree = usePanel(snapshot => snapshot.state?.worktree ?? null)
|
||||
if (worktree === null) return null
|
||||
const creation = creationOf(worktree)
|
||||
if (creation !== undefined) return <CreationCard creation={creation} stop={stop} withdraw={withdraw} t={t} />
|
||||
if (worktree.missing === null || worktree.created === null) return null
|
||||
return <MissingCard created={worktree.created} missing={worktree.missing} restore={restore} leave={leave} t={t} />
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
/* Worktree option at the right of the New Session row: the compact 28px chip
|
||||
geometry and secondary ink of the composer mode controls. */
|
||||
.root {
|
||||
display: inline-flex;
|
||||
flex: none;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toggle,
|
||||
.base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: var(--dsw-radius-sm);
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle:hover:not([data-disabled]),
|
||||
.base:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.toggle[data-disabled] {
|
||||
color: var(--dsw-alias-label-dimmed);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.base {
|
||||
gap: 4px;
|
||||
max-width: 200px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.base:focus-visible,
|
||||
.toggle:has(.checkbox:focus-visible) {
|
||||
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.base:disabled {
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.checkbox {
|
||||
flex: none;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin: 0;
|
||||
accent-color: var(--dsw-alias-brand-primary);
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.checkbox:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.baseLabel {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.chevronOpen {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.option {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.optionName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-family: var(--ds-font-family-code);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.optionNote {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/** New Session option: start the Session in a new worktree, from a chosen local branch. */
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutlineRegular, Menu, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { MenuEntry } 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 { ForgeBranches, ForgePanelState } from '../types.ts'
|
||||
import type { NS } from './locales.ts'
|
||||
import type { PanelSnapshot } from './source.ts'
|
||||
import css from './WorktreeToggle.module.css'
|
||||
|
||||
/** Business face of the New Session option. */
|
||||
export interface WorktreeToggleInjected {
|
||||
/** Panel snapshot of the Session; the renderer binds it as `usePanel`. */
|
||||
readonly hooks: { readonly panel: HostObservable<PanelSnapshot> }
|
||||
/**
|
||||
* Request a worktree from a base branch, replacing an open request.
|
||||
* @param from - base branch, or null for HEAD.
|
||||
* @returns whether the Host accepted it.
|
||||
*/
|
||||
readonly request: (from: string | null) => Promise<boolean>
|
||||
/**
|
||||
* Withdraw the open request.
|
||||
* @returns whether the Host accepted it.
|
||||
*/
|
||||
readonly cancel: () => Promise<boolean>
|
||||
/**
|
||||
* List the local branches of the Session's checkout.
|
||||
* @returns the branches, or undefined when they could not be read.
|
||||
*/
|
||||
readonly branches: () => Promise<ForgeBranches | undefined>
|
||||
}
|
||||
|
||||
/** Full props of the New Session option. */
|
||||
export type WorktreeToggleProps = InjectFace<WorktreeToggleInjected> & PropsLocale<typeof NS>
|
||||
|
||||
type Translate = WorktreeToggleProps['t']
|
||||
|
||||
function BaseMenu({ base, held, branches, request, t }: {
|
||||
readonly base: string | null
|
||||
/** Whether the worktree is being created, which freezes the base. */
|
||||
readonly held: boolean
|
||||
readonly branches: WorktreeToggleInjected['branches']
|
||||
readonly request: WorktreeToggleInjected['request']
|
||||
readonly t: Translate
|
||||
}): ReactNode {
|
||||
const [listed, setListed] = useState<ForgeBranches | null>(null)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const show = (): void => {
|
||||
if (open) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
void branches().then((result) => {
|
||||
setLoading(false)
|
||||
if (result === undefined) return
|
||||
setListed(result)
|
||||
setOpen(true)
|
||||
})
|
||||
}
|
||||
const items: MenuEntry[] = [
|
||||
{ type: 'label', id: 'title', text: t('worktree.baseMenu') },
|
||||
...(listed?.branches ?? []).map(name => ({
|
||||
id: name,
|
||||
label: name === listed?.current
|
||||
? <span className={css.option}><span className={css.optionName}>{name}</span><span className={css.optionNote}>{t('worktree.currentBranch')}</span></span>
|
||||
: <span className={css.optionName}>{name}</span>,
|
||||
})),
|
||||
]
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
items={items}
|
||||
selectedId={base ?? undefined}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
if (id !== base) void request(id)
|
||||
}}
|
||||
onClose={() => { setOpen(false) }}
|
||||
side="bottom"
|
||||
align="end"
|
||||
portal
|
||||
anchor={
|
||||
<button type="button" className={css.base} aria-haspopup="menu" aria-expanded={open} disabled={loading || held} onClick={show}>
|
||||
<span className={css.baseLabel}>{base === null ? t('worktree.baseHead') : t('worktree.base', { branch: base })}</span>
|
||||
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, open && css.chevronOpen)} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/** What the option shows for a panel state, or undefined while it stays hidden. */
|
||||
function optionOf(state: ForgePanelState | null): { readonly usable: boolean; readonly current: string | null } | undefined {
|
||||
if (state === null || !state.worktree.editable) return undefined
|
||||
const checkout = state.checkout
|
||||
if (checkout.state === 'none') return { usable: false, current: null }
|
||||
if (checkout.state !== 'ready') return undefined
|
||||
return { usable: state.worktree.blocker === null, current: checkout.checkout.branch }
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the worktree option while the Session can still start in a worktree:
|
||||
* the base branch menu once requested, then the checkbox. When the checkout
|
||||
* cannot host a worktree, the tooltip gives the reason and the checkbox only
|
||||
* withdraws an open request. Both stay fixed while the worktree is being
|
||||
* created.
|
||||
* @param props - panel snapshot hook, request actions, and copy.
|
||||
* @returns the option, or nothing once a turn started, the request settled, or no worktree service is mounted.
|
||||
*/
|
||||
export function WorktreeToggle({ usePanel, request, cancel, branches, t }: WorktreeToggleProps): ReactNode {
|
||||
const state = usePanel(snapshot => snapshot.state)
|
||||
const [pending, setPending] = useState<boolean | null>(null)
|
||||
const option = optionOf(state)
|
||||
if (state === null || option === undefined) return null
|
||||
const held = state.worktree.creation !== null
|
||||
const requested = pending ?? state.worktree.request !== null
|
||||
const disabled = held || pending !== null || (!option.usable && !requested)
|
||||
const blocker = state.worktree.blocker ?? 'not-git'
|
||||
const change = (checked: boolean): void => {
|
||||
setPending(checked)
|
||||
void (checked ? request(option.current) : cancel()).then(() => { setPending(null) })
|
||||
}
|
||||
return (
|
||||
<span className={css.root} data-forge-worktree="">
|
||||
{requested && option.usable && (
|
||||
<BaseMenu base={state.worktree.request?.from ?? option.current} held={held} branches={branches} request={request} t={t} />
|
||||
)}
|
||||
<Tooltip label={option.usable ? t('worktree.hint') : t(`worktree.blocked.${blocker}`)} side="bottom" delayMs={500}>
|
||||
<label className={css.toggle} data-disabled={disabled ? '' : undefined}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className={css.checkbox}
|
||||
checked={requested}
|
||||
disabled={disabled}
|
||||
onChange={(event) => { change(event.currentTarget.checked) }}
|
||||
/>
|
||||
{t('worktree.label')}
|
||||
</label>
|
||||
</Tooltip>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
/** Localized durations and relative times shown on change request cards. */
|
||||
|
||||
import { relativeTime } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { NS } from './locales.ts'
|
||||
|
||||
type Translate = PropsLocale<typeof NS>['t']
|
||||
|
||||
/**
|
||||
* Format an elapsed time as hours and minutes, minutes and seconds, or seconds.
|
||||
* @param ms - elapsed milliseconds.
|
||||
* @param t - forge panel copy.
|
||||
* @returns the localized duration.
|
||||
*/
|
||||
export function formatDuration(ms: number, t: Translate): string {
|
||||
const total = Math.round(ms / 1000)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const minutes = Math.floor(total % 3600 / 60)
|
||||
const seconds = total % 60
|
||||
if (hours > 0) return t('duration.hours', { hours, minutes })
|
||||
if (minutes > 0) return t('duration.minutes', { minutes, seconds })
|
||||
return t('duration.seconds', { seconds })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format how long ago an ISO 8601 time was.
|
||||
* @param iso - the time.
|
||||
* @param now - current epoch milliseconds.
|
||||
* @param t - forge panel copy.
|
||||
* @returns the localized relative time, or undefined when `iso` does not parse.
|
||||
*/
|
||||
export function formatAgo(iso: string, now: number, t: Translate): string | undefined {
|
||||
const at = Date.parse(iso)
|
||||
if (!Number.isFinite(at)) return undefined
|
||||
const { unit, n } = relativeTime(at, now)
|
||||
return t(`time.${unit}`, { n })
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Browser entry of the forge panel: the sidebar tab, the header entries, the worktree option and creation card, and their Remote. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import remote from '@deepseek-ai/dsh-experimental-client-ui-forge/remote'
|
||||
import { mountForgePanel } from './mount.ts'
|
||||
|
||||
export { inject } from './mount.ts'
|
||||
export type { ForgePanelKey } from './locales.ts'
|
||||
export type { BranchChipInjected, BranchChipProps } from './BranchChip.tsx'
|
||||
export type { ChangeRequestActions, ChangeRequestCardProps } from './ChangeRequestCard.tsx'
|
||||
export type { ForgeTabInjected, ForgeTabProps } from './ForgeTab.tsx'
|
||||
export type { HeaderButtonInjected, HeaderButtonProps } from './HeaderButton.tsx'
|
||||
export type { WorktreeProgressInjected, WorktreeProgressProps } from './WorktreeProgress.tsx'
|
||||
export type { WorktreeToggleInjected, WorktreeToggleProps } from './WorktreeToggle.tsx'
|
||||
export type { ActionNotice, NoticeOptions, PanelSnapshot } from './source.ts'
|
||||
|
||||
/**
|
||||
* Mount the forge panel Remote and its UI.
|
||||
* @param ctx - browser plugin context.
|
||||
* @returns the disposer that withdraws the UI, then the Remote.
|
||||
*/
|
||||
export async function apply(ctx: Context): Promise<() => Promise<void>> {
|
||||
return await mountForgePanel(ctx, remote)
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/** Forge panel copy owned by the locale dictionaries. */
|
||||
|
||||
/** Locale namespace of the forge panel. */
|
||||
export const NS = 'forgePanel'
|
||||
|
||||
/** Chinese copy; its keys define {@link ForgePanelKey}. */
|
||||
export const zh = {
|
||||
'tab.title': 'PR',
|
||||
'header.label': '本会话的 PR',
|
||||
'header.attention': '本会话的 PR:有需要处理的项',
|
||||
'checkout.worktree': '工作树',
|
||||
'checkout.branch': '分支',
|
||||
'checkout.basedOn': '基于 {base}',
|
||||
'checkout.current': '当前检出',
|
||||
'checkout.detached': '游离的 HEAD',
|
||||
'checkout.none': '当前目录不是 Git 仓库',
|
||||
'checkout.failed': '无法读取 Git 信息',
|
||||
'changes.title': '本会话的 Pull Request · {count}',
|
||||
'changes.empty': '本会话还没有 PR',
|
||||
'changes.emptyHint': 'agent 用 gh pr create 创建 PR,或推送了已有 PR 的分支后,会自动出现在这里',
|
||||
'signin.title': '登录 {host} 后才能显示 PR 状态',
|
||||
'signin.copy': '复制登录命令',
|
||||
'signin.unavailable': '无法读取 {host} 上的 PR:{message}',
|
||||
'refresh': '刷新状态',
|
||||
'retry': '重试',
|
||||
'stream.failed': '面板已断开',
|
||||
'change.number': '#{number}',
|
||||
'change.loading': '正在读取状态',
|
||||
'change.state.open': '可供审查',
|
||||
'change.state.draft': '草稿',
|
||||
'change.state.merged': '已合并',
|
||||
'change.state.closed': '已关闭',
|
||||
'change.origin.created': '本会话创建',
|
||||
'change.origin.discovered': '推送后发现',
|
||||
'change.origin.tracked': '手动跟踪',
|
||||
'change.ci.none': '没有检查',
|
||||
'change.ci.running': '检查中',
|
||||
'change.ci.passed': '已通过',
|
||||
'change.ci.failed': '{count} 项失败',
|
||||
'change.ci.failedUnlisted': '检查失败',
|
||||
'change.checks.failed': '{count} 项失败',
|
||||
'change.checks.running': '{count} 项进行中',
|
||||
'change.checks.passed': '{count} 项通过',
|
||||
'change.checks.other': '{count} 项未运行',
|
||||
'change.checks.truncated': '只列出前 {count} 项',
|
||||
'change.check.running': '进行中',
|
||||
'change.check.queued': '排队中',
|
||||
'change.flag.changesRequested': '要求修改',
|
||||
'change.flag.conflicting': '有冲突',
|
||||
'change.flag.draft': '草稿',
|
||||
'change.failure.unavailable': '暂时读不到状态',
|
||||
'change.failure.notFound': '找不到这个 PR',
|
||||
'change.failure.unauthenticated': '登录后才能读取状态',
|
||||
'change.unserved': '暂不支持读取这个平台的状态',
|
||||
'change.view': '查看 PR',
|
||||
'change.copy': '复制 PR 链接',
|
||||
'change.row.state': '状态',
|
||||
'change.row.checks': '检查',
|
||||
'change.row.review': '评审',
|
||||
'change.row.merge': '合并',
|
||||
'change.review.approved': '已批准',
|
||||
'change.review.changesRequested': '要求修改',
|
||||
'change.review.required': '等待评审',
|
||||
'change.review.none': '尚未请求评审',
|
||||
'change.review.threads': '{review} · {count} 条未解决讨论',
|
||||
'change.merge.mergeable': '可合并',
|
||||
'change.merge.conflicting': '有冲突',
|
||||
'change.merge.unknown': '正在检查',
|
||||
'change.addToChat': '添加到聊天',
|
||||
'change.remove': '从会话中移除 PR',
|
||||
'time.now': '刚刚',
|
||||
'time.minutes': '{n} 分钟前',
|
||||
'time.hours': '{n} 小时前',
|
||||
'time.days': '{n} 天前',
|
||||
'time.months': '{n} 个月前',
|
||||
'time.years': '{n} 年前',
|
||||
'time.finished': '{state} · {time}',
|
||||
'duration.seconds': '{seconds} 秒',
|
||||
'duration.minutes': '{minutes} 分 {seconds} 秒',
|
||||
'duration.hours': '{hours} 小时 {minutes} 分',
|
||||
'worktree.label': '工作树',
|
||||
'worktree.hint': '在新的工作树里运行本会话:发送后从所选分支拉出新分支,修改和提交都不影响当前检出',
|
||||
'worktree.blocked.not-git': '当前工作区不是 Git 仓库,不能以工作树创建',
|
||||
'worktree.blocked.outside-writable-root': '这个工作区的 Git 目录不在可写范围内(例如它本身是链接工作树),暂不能以工作树创建',
|
||||
'worktree.blocked.read-only': '只读模式下不能以工作树创建',
|
||||
'worktree.base': '基于 {branch}',
|
||||
'worktree.baseHead': '基于当前 HEAD',
|
||||
'worktree.baseMenu': '从哪个分支创建工作树',
|
||||
'worktree.currentBranch': '当前分支',
|
||||
'creation.title': '正在创建工作树',
|
||||
'creation.failed': '创建工作树失败',
|
||||
'creation.prepare': '准备工作空间',
|
||||
'creation.checkout': '检出文件',
|
||||
'creation.details': '更多详情',
|
||||
'creation.branch': '分支',
|
||||
'creation.base': '基于',
|
||||
'creation.head': '当前 HEAD',
|
||||
'creation.error': '原因',
|
||||
'creation.skip': '不使用工作树',
|
||||
'creation.cancel': '取消',
|
||||
'creation.retryHint': '再次发送消息即可重试',
|
||||
'missing.title': '工作树已被删除',
|
||||
'missing.kept': '可以在原路径重新检出分支 {branch};删除前未提交的修改无法找回',
|
||||
'missing.recreated': '分支 {branch} 也已删除,恢复会从创建时的提交重新创建它;之后的提交和未提交的修改都无法找回',
|
||||
'missing.restore': '恢复工作树',
|
||||
'missing.leave': '在项目目录继续',
|
||||
'branch.worktree': '工作树:{path},基于 {base} 创建',
|
||||
'branch.current': '当前检出:{root}',
|
||||
'branch.missing': '工作树已被删除:{path}',
|
||||
'toast.removed': '已从会话中移除 {name}',
|
||||
'toast.undo': '撤销',
|
||||
'toast.undoFailed': '撤销失败',
|
||||
'toast.removeFailed': '移除失败',
|
||||
'toast.refreshFailed': '刷新失败',
|
||||
'toast.copied': '已复制',
|
||||
'toast.copyFailed': '复制失败',
|
||||
'toast.chatFailed': '输入框正在发送,暂时不能添加',
|
||||
'toast.branchesFailed': '无法读取分支',
|
||||
'toast.worktreeFailed': '无法设置工作树',
|
||||
'toast.worktreeStarted': '会话已经开始,不能再改为工作树',
|
||||
'toast.restored': '工作树已恢复',
|
||||
'toast.leaveFailed': '没能切回项目目录,请稍后重试',
|
||||
} as const
|
||||
|
||||
/** Key of the forge panel dictionaries. */
|
||||
export type ForgePanelKey = keyof typeof zh
|
||||
|
||||
/** English copy with the same keys. */
|
||||
export const en: Record<ForgePanelKey, string> = {
|
||||
'tab.title': 'PRs',
|
||||
'header.label': 'Pull requests of this Session',
|
||||
'header.attention': 'Pull requests of this Session need attention',
|
||||
'checkout.worktree': 'Worktree',
|
||||
'checkout.branch': 'Branch',
|
||||
'checkout.basedOn': 'From {base}',
|
||||
'checkout.current': 'Current checkout',
|
||||
'checkout.detached': 'Detached HEAD',
|
||||
'checkout.none': 'This directory is not a Git repository',
|
||||
'checkout.failed': 'Git information could not be read',
|
||||
'changes.title': 'Pull requests of this Session · {count}',
|
||||
'changes.empty': 'This Session has no pull requests yet',
|
||||
'changes.emptyHint': 'Pull requests appear here when the agent runs gh pr create or pushes a branch that has one',
|
||||
'signin.title': 'Sign in to {host} to show pull request status',
|
||||
'signin.copy': 'Copy sign-in command',
|
||||
'signin.unavailable': 'Pull requests on {host} could not be read: {message}',
|
||||
'refresh': 'Refresh status',
|
||||
'retry': 'Retry',
|
||||
'stream.failed': 'The panel is disconnected',
|
||||
'change.number': '#{number}',
|
||||
'change.loading': 'Reading status',
|
||||
'change.state.open': 'Ready for review',
|
||||
'change.state.draft': 'Draft',
|
||||
'change.state.merged': 'Merged',
|
||||
'change.state.closed': 'Closed',
|
||||
'change.origin.created': 'Created in this Session',
|
||||
'change.origin.discovered': 'Found after a push',
|
||||
'change.origin.tracked': 'Tracked',
|
||||
'change.ci.none': 'No checks',
|
||||
'change.ci.running': 'Checks running',
|
||||
'change.ci.passed': 'Passed',
|
||||
'change.ci.failed': '{count} failed',
|
||||
'change.ci.failedUnlisted': 'Checks failed',
|
||||
'change.checks.failed': '{count} failed',
|
||||
'change.checks.running': '{count} running',
|
||||
'change.checks.passed': '{count} passed',
|
||||
'change.checks.other': '{count} not run',
|
||||
'change.checks.truncated': 'Only the first {count} are listed',
|
||||
'change.check.running': 'Running',
|
||||
'change.check.queued': 'Queued',
|
||||
'change.flag.changesRequested': 'Changes requested',
|
||||
'change.flag.conflicting': 'Conflicts',
|
||||
'change.flag.draft': 'Draft',
|
||||
'change.failure.unavailable': 'Status is unavailable right now',
|
||||
'change.failure.notFound': 'This pull request was not found',
|
||||
'change.failure.unauthenticated': 'Sign in to read its status',
|
||||
'change.unserved': 'Status is not supported for this code host yet',
|
||||
'change.view': 'View pull request',
|
||||
'change.copy': 'Copy pull request link',
|
||||
'change.row.state': 'State',
|
||||
'change.row.checks': 'Checks',
|
||||
'change.row.review': 'Review',
|
||||
'change.row.merge': 'Merge',
|
||||
'change.review.approved': 'Approved',
|
||||
'change.review.changesRequested': 'Changes requested',
|
||||
'change.review.required': 'Review required',
|
||||
'change.review.none': 'No review requested',
|
||||
'change.review.threads': '{review} · {count} unresolved',
|
||||
'change.merge.mergeable': 'Mergeable',
|
||||
'change.merge.conflicting': 'Conflicts',
|
||||
'change.merge.unknown': 'Checking',
|
||||
'change.addToChat': 'Add to chat',
|
||||
'change.remove': 'Remove from Session',
|
||||
'time.now': 'just now',
|
||||
'time.minutes': '{n} min ago',
|
||||
'time.hours': '{n} h ago',
|
||||
'time.days': '{n} d ago',
|
||||
'time.months': '{n} mo ago',
|
||||
'time.years': '{n} y ago',
|
||||
'time.finished': '{state} · {time}',
|
||||
'duration.seconds': '{seconds}s',
|
||||
'duration.minutes': '{minutes}m {seconds}s',
|
||||
'duration.hours': '{hours}h {minutes}m',
|
||||
'worktree.label': 'Worktree',
|
||||
'worktree.hint': 'Run this Session in a new worktree: sending starts a new branch from the chosen one, so changes and commits leave the current checkout alone',
|
||||
'worktree.blocked.not-git': 'This workspace is not a Git repository, so it cannot start in a worktree',
|
||||
'worktree.blocked.outside-writable-root': 'The Git directory of this workspace is outside the writable area, such as for a linked worktree, so it cannot start in a worktree yet',
|
||||
'worktree.blocked.read-only': 'A read-only Session cannot start in a worktree',
|
||||
'worktree.base': 'From {branch}',
|
||||
'worktree.baseHead': 'From the current HEAD',
|
||||
'worktree.baseMenu': 'Branch to start the worktree from',
|
||||
'worktree.currentBranch': 'Current',
|
||||
'creation.title': 'Creating the worktree',
|
||||
'creation.failed': 'The worktree could not be created',
|
||||
'creation.prepare': 'Prepare the workspace',
|
||||
'creation.checkout': 'Check out files',
|
||||
'creation.details': 'More details',
|
||||
'creation.branch': 'Branch',
|
||||
'creation.base': 'From',
|
||||
'creation.head': 'Current HEAD',
|
||||
'creation.error': 'Reason',
|
||||
'creation.skip': 'Continue without a worktree',
|
||||
'creation.cancel': 'Cancel',
|
||||
'creation.retryHint': 'Send the message again to retry',
|
||||
'missing.title': 'The worktree was removed',
|
||||
'missing.kept': 'Branch {branch} can be checked out again at the same path; uncommitted changes from before the removal cannot be recovered',
|
||||
'missing.recreated': 'Branch {branch} was removed too; restoring creates it again at the commit it started from, without its later commits or uncommitted changes',
|
||||
'missing.restore': 'Restore worktree',
|
||||
'missing.leave': 'Continue in the project',
|
||||
'branch.worktree': 'Worktree at {path}, created from {base}',
|
||||
'branch.current': 'Current checkout at {root}',
|
||||
'branch.missing': 'Worktree removed: {path}',
|
||||
'toast.removed': 'Removed {name} from this Session',
|
||||
'toast.undo': 'Undo',
|
||||
'toast.undoFailed': 'Undo failed',
|
||||
'toast.removeFailed': 'Removal failed',
|
||||
'toast.refreshFailed': 'Refresh failed',
|
||||
'toast.copied': 'Copied',
|
||||
'toast.copyFailed': 'Copy failed',
|
||||
'toast.chatFailed': 'The message is sending, so nothing can be added right now',
|
||||
'toast.branchesFailed': 'Branches could not be read',
|
||||
'toast.worktreeFailed': 'The worktree option could not change',
|
||||
'toast.worktreeStarted': 'This Session has started, so it can no longer use a new worktree',
|
||||
'toast.restored': 'Worktree restored',
|
||||
'toast.leaveFailed': 'Could not switch back to the project; try again',
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
interface LocaleNamespaceMap {
|
||||
/** Forge panel tab, header entries, worktree option and creation card, and action notices. */
|
||||
forgePanel: ForgePanelKey
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/** Remote contribution and slot registrations of the forge panel. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-api-remotes/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge/remote'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ForgePanelChangeRequest } from '../types.ts'
|
||||
import { ActionToast } from './ActionToast.tsx'
|
||||
import { BranchChip, type BranchChipInjected } from './BranchChip.tsx'
|
||||
import { ForgeTab, type ForgeTabInjected } from './ForgeTab.tsx'
|
||||
import { ForgeTabTitle } from './ForgeTabTitle.tsx'
|
||||
import { HeaderButton, type HeaderButtonInjected } from './HeaderButton.tsx'
|
||||
import { en, NS, zh, type ForgePanelKey } from './locales.ts'
|
||||
import {
|
||||
createNoticeSource, createPanelSource, createVisibilitySource, type PanelSource, type VisibilitySource,
|
||||
} from './source.ts'
|
||||
import { numberOf } from './status.ts'
|
||||
import { WorktreeProgress, type WorktreeProgressInjected } from './WorktreeProgress.tsx'
|
||||
import { WorktreeToggle, type WorktreeToggleInjected } from './WorktreeToggle.tsx'
|
||||
|
||||
/** Services required before the Remote contribution mounts. */
|
||||
export const inject = ['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight']
|
||||
|
||||
/** Registry id of the forge tab type, also its slot key. */
|
||||
export const TAB_ID = '@deepseek-ai/dsh-experimental-client-ui-forge'
|
||||
|
||||
/** Kind of the forge tab. */
|
||||
export const TAB_KIND = 'forge-panel'
|
||||
|
||||
/**
|
||||
* Open a URL in a new browsing context without giving it access to this page.
|
||||
* @param url - change request web URL.
|
||||
*/
|
||||
function openLink(url: string): void {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
/** Per-Session stores shared by the tab, the header entries, the New Session option, and the worktree card. */
|
||||
interface SessionStores {
|
||||
readonly panel: PanelSource
|
||||
readonly visibility: VisibilitySource
|
||||
}
|
||||
|
||||
function registerUi(ctx: Context): void {
|
||||
ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'forge-panel: dictionaries')
|
||||
const t = ctx.locale.bind(NS)
|
||||
const notices = createNoticeSource()
|
||||
const stores = new Map<SessionId, SessionStores>()
|
||||
ctx.effect(() => () => {
|
||||
for (const store of stores.values()) store.panel.close()
|
||||
stores.clear()
|
||||
}, 'forge-panel: panel streams')
|
||||
const storesOf = (sessionId: SessionId): SessionStores => {
|
||||
let store = stores.get(sessionId)
|
||||
if (store === undefined) {
|
||||
store = {
|
||||
panel: createPanelSource({
|
||||
open: signal => ctx.remote.forgePanel.follow(sessionId, signal),
|
||||
subscribeReset: listener => ctx.on('connection/reset', listener),
|
||||
}),
|
||||
visibility: createVisibilitySource(),
|
||||
}
|
||||
stores.set(sessionId, store)
|
||||
}
|
||||
return store
|
||||
}
|
||||
const settled = (result: RemoteResult<unknown>, failure: ForgePanelKey, codes: Partial<Record<string, ForgePanelKey>> = {}): boolean => {
|
||||
if (result.ok) return true
|
||||
notices.report(codes[result.error.code] ?? failure)
|
||||
return false
|
||||
}
|
||||
const copyText = (text: string): void => {
|
||||
void navigator.clipboard.writeText(text).then(
|
||||
() => { notices.report('toast.copied', { tone: 'success' }) },
|
||||
() => { notices.report('toast.copyFailed') },
|
||||
)
|
||||
}
|
||||
const remove = (sessionId: SessionId, entry: ForgePanelChangeRequest): void => {
|
||||
void ctx.remote.forgePanel.dismiss(sessionId, entry.url).then((result) => {
|
||||
if (!settled(result, 'toast.removeFailed')) return
|
||||
const number = numberOf(entry)
|
||||
notices.report('toast.removed', {
|
||||
tone: 'success',
|
||||
params: { name: number === null ? entry.snapshot?.title ?? entry.url : t('change.number', { number }) },
|
||||
// Restoring needs a provider that recognizes the URL; an unrecognized one is removed without undo.
|
||||
...entry.ref === null ? {} : {
|
||||
undo: () => {
|
||||
void ctx.remote.forgePanel.track(sessionId, entry.url).then(restored => settled(restored, 'toast.undoFailed'))
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
const tabFace = (sessionId: SessionId): ForgeTabInjected => {
|
||||
const store = storesOf(sessionId)
|
||||
return {
|
||||
hooks: store.panel.hooks,
|
||||
reconnect: store.panel.reconnect,
|
||||
refresh: () => {
|
||||
void ctx.remote.forgePanel.refresh(sessionId).then(result => settled(result, 'toast.refreshFailed'))
|
||||
},
|
||||
remove: (entry) => { remove(sessionId, entry) },
|
||||
openLink,
|
||||
copyText,
|
||||
report: (key) => { notices.report(key) },
|
||||
reportVisible: store.visibility.set,
|
||||
}
|
||||
}
|
||||
const progressFace = (sessionId: SessionId): WorktreeProgressInjected => {
|
||||
const store = storesOf(sessionId)
|
||||
return {
|
||||
hooks: { panel: store.panel.hooks.panel },
|
||||
stop: () => {
|
||||
void ctx.remote.forgePanel.stopCreation(sessionId).then(result => settled(result, 'toast.worktreeFailed'))
|
||||
},
|
||||
withdraw: () => {
|
||||
void ctx.remote.forgePanel.cancelWorktree(sessionId).then(result => settled(result, 'toast.worktreeFailed'))
|
||||
},
|
||||
restore: async () => {
|
||||
const result = await ctx.remote.forgePanel.restoreWorktree(sessionId)
|
||||
if (!result.ok) return result.error.message
|
||||
notices.report('toast.restored', { tone: 'success' })
|
||||
return undefined
|
||||
},
|
||||
leave: () => {
|
||||
void ctx.remote.forgePanel.continueInProject(sessionId).then(result => settled(result, 'toast.leaveFailed'))
|
||||
},
|
||||
}
|
||||
}
|
||||
const worktreeFace = (sessionId: SessionId): WorktreeToggleInjected => {
|
||||
const worktreeCodes: Partial<Record<string, ForgePanelKey>> = { 'forgePanel/session-started': 'toast.worktreeStarted' }
|
||||
const store = storesOf(sessionId)
|
||||
return {
|
||||
hooks: { panel: store.panel.hooks.panel },
|
||||
request: async from => settled(await ctx.remote.forgePanel.requestWorktree(sessionId, from), 'toast.worktreeFailed', worktreeCodes),
|
||||
cancel: async () => settled(await ctx.remote.forgePanel.cancelWorktree(sessionId), 'toast.worktreeFailed', worktreeCodes),
|
||||
branches: async () => {
|
||||
const result = await ctx.remote.forgePanel.branches(sessionId)
|
||||
return settled(result, 'toast.branchesFailed') && result.ok ? result.value : undefined
|
||||
},
|
||||
}
|
||||
}
|
||||
ctx.effect(() => ctx.sidebarRightTabs.register({ id: TAB_ID, kind: TAB_KIND, title: () => t('tab.title') }), 'forge-panel: sidebar tab')
|
||||
ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab', key: TAB_ID, locale: NS, inject: tabFace,
|
||||
}, ForgeTab))
|
||||
ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
|
||||
name: 'sidebar.right.pane.tab.title', key: TAB_ID, locale: NS,
|
||||
}, ForgeTabTitle))
|
||||
ctx.slots.inject('conversation.session.header.utilities', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.utilities',
|
||||
id: 'forge-panel',
|
||||
// Left of the schedule entry (-5) and the overflow menu (0), right of open-in-app (-10).
|
||||
order: -8,
|
||||
locale: NS,
|
||||
inject: (sessionId: SessionId): HeaderButtonInjected => {
|
||||
const store = storesOf(sessionId)
|
||||
return {
|
||||
hooks: { panel: store.panel.hooks.panel, visible: store.visibility.hooks.visible },
|
||||
toggle: (visible) => {
|
||||
if (visible) ctx.sidebarRight.toggleExpanded()
|
||||
else ctx.sidebarRight.openTab(TAB_KIND)
|
||||
},
|
||||
}
|
||||
},
|
||||
}, HeaderButton))
|
||||
ctx.slots.inject('conversation.hero.options', () => ctx.slots.register({
|
||||
name: 'conversation.hero.options', id: 'forge-panel.worktree', locale: NS, inject: worktreeFace,
|
||||
}, WorktreeToggle))
|
||||
ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
||||
name: 'conversation.input.dock', id: 'forge-panel.worktree', order: 5, locale: NS, inject: progressFace,
|
||||
}, WorktreeProgress))
|
||||
ctx.slots.inject('conversation.session.header.actions', () => ctx.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'forge-panel.branch',
|
||||
// After the subagent lineage (-30), Team navigation (-20), and the preset label (-10).
|
||||
order: 10,
|
||||
locale: NS,
|
||||
inject: (sessionId: SessionId): BranchChipInjected => ({ hooks: storesOf(sessionId).panel.hooks }),
|
||||
}, BranchChip))
|
||||
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
||||
name: 'shell.overlay', id: 'forge-panel.toast', locale: NS,
|
||||
inject: () => ({ hooks: notices.hooks, dismiss: notices.dismiss }),
|
||||
}, ActionToast))
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the forge panel Remote, then the UI that calls it; the UI is withdrawn before the Remote.
|
||||
* @param ctx - client plugin lifetime.
|
||||
* @param contribution - generated forge panel Remote definitions.
|
||||
* @returns the disposer that removes both contributions.
|
||||
*/
|
||||
export async function mountForgePanel(ctx: Context, contribution: TypertRemoteContribution): Promise<() => Promise<void>> {
|
||||
const unmountRemote = await ctx.remote.$mount(contribution)
|
||||
const ui = ctx.inject(['remote.forgePanel', ...inject.slice(1)], registerUi)
|
||||
try {
|
||||
await ui
|
||||
} catch (error) {
|
||||
await ui.dispose()
|
||||
await unmountRemote()
|
||||
throw error
|
||||
}
|
||||
return async () => {
|
||||
await ui.dispose()
|
||||
await unmountRemote()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/** Per-Session panel stream sources, the store behind action notices, and the tab visibility store. */
|
||||
|
||||
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ForgePanelState } from '../types.ts'
|
||||
import type { ForgePanelKey } from './locales.ts'
|
||||
|
||||
/** What the panel surfaces of one Session read. */
|
||||
export interface PanelSnapshot {
|
||||
/** Latest state from the Host, or null before the first one arrives. */
|
||||
readonly state: ForgePanelState | null
|
||||
/** Whether the stream ended while surfaces still read it. */
|
||||
readonly disconnected: boolean
|
||||
}
|
||||
|
||||
/** One open stream of panel states. */
|
||||
export interface PanelStream extends AsyncIterable<ForgePanelState> {
|
||||
/** Close the stream. */
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
/** Stream opener and connection-reset subscription of one Session's source. */
|
||||
export interface PanelSourceDeps {
|
||||
/**
|
||||
* Open the Host stream of the Session.
|
||||
* @param signal - aborts the stream.
|
||||
* @returns the stream.
|
||||
*/
|
||||
readonly open: (signal: AbortSignal) => PanelStream
|
||||
/**
|
||||
* Subscribe to transport resets, after which an open stream no longer delivers.
|
||||
* @param listener - called on each reset.
|
||||
* @returns the unsubscribe function.
|
||||
*/
|
||||
readonly subscribeReset: (listener: () => void) => () => void
|
||||
}
|
||||
|
||||
/** Panel state of one Session, streamed only while a surface subscribes. */
|
||||
export interface PanelSource {
|
||||
/** Snapshot observable; the renderer binds it as `usePanel`. */
|
||||
readonly hooks: { readonly panel: HostObservable<PanelSnapshot> }
|
||||
/** Open the stream again after it ended; a no-op while it runs or nobody subscribes. */
|
||||
readonly reconnect: () => void
|
||||
/** Close the stream and the reset subscription and drop every subscriber. */
|
||||
readonly close: () => void
|
||||
}
|
||||
|
||||
const EMPTY: PanelSnapshot = { state: null, disconnected: false }
|
||||
|
||||
/**
|
||||
* Create the panel source of one Session. The first subscriber opens the
|
||||
* stream and the last one closes it; the last state stays readable for the
|
||||
* next subscriber. A stream that ends or fails while subscribed marks the
|
||||
* snapshot disconnected until `reconnect` or a transport reset reopens it.
|
||||
* @param deps - stream opener and reset subscription.
|
||||
* @returns the source.
|
||||
*/
|
||||
export function createPanelSource(deps: PanelSourceDeps): PanelSource {
|
||||
let snapshot = EMPTY
|
||||
const listeners = new Set<() => void>()
|
||||
let running: { readonly controller: AbortController; readonly stream: PanelStream } | undefined
|
||||
let stopReset: (() => void) | undefined
|
||||
const publish = (next: PanelSnapshot): void => {
|
||||
snapshot = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
const stop = (): void => {
|
||||
const stopped = running
|
||||
running = undefined
|
||||
stopped?.controller.abort()
|
||||
stopped?.stream.dispose()
|
||||
}
|
||||
const start = (): void => {
|
||||
const controller = new AbortController()
|
||||
const opened = { controller, stream: deps.open(controller.signal) }
|
||||
running = opened
|
||||
if (snapshot.disconnected) publish({ ...snapshot, disconnected: false })
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const state of opened.stream) {
|
||||
if (running !== opened) return
|
||||
publish({ state, disconnected: false })
|
||||
}
|
||||
} catch (error) {
|
||||
void error // A failed stream is reported as the disconnection below.
|
||||
}
|
||||
if (running !== opened) return
|
||||
running = undefined
|
||||
publish({ ...snapshot, disconnected: true })
|
||||
})()
|
||||
}
|
||||
const reconnect = (): void => {
|
||||
if (listeners.size === 0 || running !== undefined) return
|
||||
start()
|
||||
}
|
||||
const close = (): void => {
|
||||
listeners.clear()
|
||||
stopReset?.()
|
||||
stopReset = undefined
|
||||
stop()
|
||||
}
|
||||
return {
|
||||
hooks: {
|
||||
panel: {
|
||||
getSnapshot: () => snapshot,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
if (listeners.size === 1) {
|
||||
stopReset = deps.subscribeReset(() => {
|
||||
stop()
|
||||
reconnect()
|
||||
})
|
||||
start()
|
||||
}
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (listeners.size === 0) close()
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
reconnect,
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
/** One action notice; `seq` keys the toast so a repeated notice restarts it. */
|
||||
export interface ActionNotice {
|
||||
/** Dictionary key of the notice text. */
|
||||
readonly key: ForgePanelKey
|
||||
/** Values interpolated into the text. */
|
||||
readonly params: Readonly<Record<string, string | number>>
|
||||
/** `success` reports a completed action; `failure` a refused or failed one. */
|
||||
readonly tone: 'success' | 'failure'
|
||||
/** Reverts the reported action, or undefined when it cannot be undone. */
|
||||
readonly undo: (() => void) | undefined
|
||||
/** Increasing report number. */
|
||||
readonly seq: number
|
||||
}
|
||||
|
||||
/** Optional parts of a reported notice. */
|
||||
export interface NoticeOptions {
|
||||
/** Values interpolated into the text. */
|
||||
readonly params?: Readonly<Record<string, string | number>>
|
||||
/** Defaults to `failure`. */
|
||||
readonly tone?: ActionNotice['tone']
|
||||
/** Reverts the reported action. */
|
||||
readonly undo?: () => void
|
||||
}
|
||||
|
||||
/** Store that action wiring reports into and one `shell.overlay` entry reads. */
|
||||
export interface NoticeSource {
|
||||
/** Current notice, or null while none shows; the renderer binds it as `useNotice`. */
|
||||
readonly hooks: { readonly notice: HostObservable<ActionNotice | null> }
|
||||
/**
|
||||
* Show one notice, replacing the shown one.
|
||||
* @param key - dictionary key of the text.
|
||||
* @param options - interpolated values, tone, and undo action.
|
||||
*/
|
||||
readonly report: (key: ForgePanelKey, options?: NoticeOptions) => void
|
||||
/** Clear the shown notice. */
|
||||
readonly dismiss: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the action notice store.
|
||||
* @returns the store.
|
||||
*/
|
||||
export function createNoticeSource(): NoticeSource {
|
||||
let notice: ActionNotice | null = null
|
||||
let seq = 0
|
||||
const listeners = new Set<() => void>()
|
||||
const publish = (next: ActionNotice | null): void => {
|
||||
notice = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
}
|
||||
return {
|
||||
hooks: {
|
||||
notice: {
|
||||
getSnapshot: () => notice,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
},
|
||||
report: (key, options = {}) => {
|
||||
publish({ key, params: options.params ?? {}, tone: options.tone ?? 'failure', undo: options.undo, seq: ++seq })
|
||||
},
|
||||
dismiss: () => { publish(null) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether one Session's forge tab is visible; the header entry reads it for its pressed state. */
|
||||
export interface VisibilitySource {
|
||||
/** Current visibility; the renderer binds it as `useVisible`. */
|
||||
readonly hooks: { readonly visible: HostObservable<boolean> }
|
||||
/**
|
||||
* Record the tab's visibility.
|
||||
* @param visible - whether the tab is visible.
|
||||
*/
|
||||
readonly set: (visible: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the visibility store of one Session's forge tab, initially hidden.
|
||||
* @returns the store.
|
||||
*/
|
||||
export function createVisibilitySource(): VisibilitySource {
|
||||
let visible = false
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
hooks: {
|
||||
visible: {
|
||||
getSnapshot: () => visible,
|
||||
subscribe(listener) {
|
||||
listeners.add(listener)
|
||||
return () => { listeners.delete(listener) }
|
||||
},
|
||||
},
|
||||
},
|
||||
set: (next) => {
|
||||
if (next === visible) return
|
||||
visible = next
|
||||
for (const listener of [...listeners]) listener()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/** Status derivations shared by the header entry and the change request cards. */
|
||||
|
||||
import type { ChangeRequestCheck, ChangeRequestSnapshot, ChecksRollup } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type { ForgePanelChangeRequest } from '../types.ts'
|
||||
|
||||
/** Glyph tone of a change request: its lifecycle state, or `unknown` before the first snapshot. */
|
||||
export type ChangeRequestTone = 'open' | 'draft' | 'merged' | 'closed' | 'unknown'
|
||||
|
||||
/** Combined check outcome shown on a collapsed card. */
|
||||
export type CiSummary =
|
||||
| { readonly kind: 'none' }
|
||||
| { readonly kind: 'running' }
|
||||
| { readonly kind: 'passed' }
|
||||
/** `count` is the number of failed checks the host listed, which can be zero when the list is truncated. */
|
||||
| { readonly kind: 'failed'; readonly count: number }
|
||||
|
||||
/** Checks grouped by outcome; `other` holds neutral, skipped, and cancelled checks. */
|
||||
export interface CheckCounts {
|
||||
readonly failed: number
|
||||
readonly running: number
|
||||
readonly passed: number
|
||||
readonly other: number
|
||||
}
|
||||
|
||||
const SETTLED: Record<Exclude<ChecksRollup, 'failure'>, Exclude<CiSummary['kind'], 'failed'>> = {
|
||||
none: 'none',
|
||||
pending: 'running',
|
||||
success: 'passed',
|
||||
}
|
||||
|
||||
/** The most urgent fact of an open change request that the collapsed card names. */
|
||||
export type ChangeRequestFlag = 'changesRequested' | 'conflicting' | 'draft'
|
||||
|
||||
/**
|
||||
* Pick the glyph tone of a change request.
|
||||
* @param snapshot - latest snapshot, or null before the first one.
|
||||
* @returns the tone.
|
||||
*/
|
||||
export function toneOf(snapshot: ChangeRequestSnapshot | null): ChangeRequestTone {
|
||||
if (snapshot === null) return 'unknown'
|
||||
if (snapshot.state === 'open') return snapshot.draft ? 'draft' : 'open'
|
||||
return snapshot.state
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the listed checks by outcome.
|
||||
* @param checks - checks of the head commit.
|
||||
* @returns the counts.
|
||||
*/
|
||||
export function checkCounts(checks: readonly ChangeRequestCheck[]): CheckCounts {
|
||||
let failed = 0
|
||||
let running = 0
|
||||
let passed = 0
|
||||
for (const check of checks) {
|
||||
if (check.state === 'failure') failed++
|
||||
else if (check.state === 'pending') running++
|
||||
else if (check.state === 'success') passed++
|
||||
}
|
||||
return { failed, running, passed, other: checks.length - failed - running - passed }
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarize the checks of a change request from the host's combined outcome.
|
||||
* @param snapshot - change request status.
|
||||
* @returns the combined outcome with the failed count.
|
||||
*/
|
||||
export function ciSummary(snapshot: ChangeRequestSnapshot): CiSummary {
|
||||
if (snapshot.checksRollup === 'failure') return { kind: 'failed', count: checkCounts(snapshot.checks).failed }
|
||||
return { kind: SETTLED[snapshot.checksRollup] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the flag a collapsed card names: requested changes before merge
|
||||
* conflicts before draft state; only open change requests carry one.
|
||||
* @param snapshot - change request status.
|
||||
* @returns the flag, or undefined when none applies.
|
||||
*/
|
||||
export function flagOf(snapshot: ChangeRequestSnapshot): ChangeRequestFlag | undefined {
|
||||
if (snapshot.state !== 'open') return undefined
|
||||
if (snapshot.reviewDecision === 'changes-requested') return 'changesRequested'
|
||||
if (snapshot.mergeability === 'conflicting') return 'conflicting'
|
||||
return snapshot.draft ? 'draft' : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an open change request needs the user: failed checks, requested changes, or merge conflicts.
|
||||
* @param entry - bound change request.
|
||||
* @returns whether the header marks it.
|
||||
*/
|
||||
export function needsAttention(entry: ForgePanelChangeRequest): boolean {
|
||||
const snapshot = entry.snapshot
|
||||
if (snapshot === null || snapshot.state !== 'open') return false
|
||||
return snapshot.checksRollup === 'failure' || snapshot.reviewDecision === 'changes-requested' || snapshot.mergeability === 'conflicting'
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the change request number: the provider's reference, else the number at the end of the URL path.
|
||||
* @param entry - bound change request.
|
||||
* @returns the number, or null when neither names one.
|
||||
*/
|
||||
export function numberOf(entry: Pick<ForgePanelChangeRequest, 'ref' | 'url'>): number | null {
|
||||
if (entry.ref !== null) return entry.ref.number
|
||||
const [, digits = ''] = /\/(\d+)\/?(?:[?#].*)?$/s.exec(entry.url) ?? []
|
||||
const number = Number(digits)
|
||||
return digits.length > 0 && Number.isSafeInteger(number) ? number : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Measure how long a completed check ran.
|
||||
* @param check - one check.
|
||||
* @returns elapsed milliseconds, or undefined when either time is unknown or they are out of order.
|
||||
*/
|
||||
export function checkDuration(check: ChangeRequestCheck): number | undefined {
|
||||
if (check.startedAt === null || check.completedAt === null) return undefined
|
||||
const elapsed = Date.parse(check.completedAt) - Date.parse(check.startedAt)
|
||||
return Number.isFinite(elapsed) && elapsed >= 0 ? elapsed : undefined
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
/** CSS module names emitted by the browser bundle. */
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/** Read-only Git inspection of a Session's current directory in the Session's execution world. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { SubprocessCollectedOutputs } from '@deepseek-ai/dsh-subprocess'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { baseBranches, parseName, parseRemotes, parseTopLevel } from './parse.ts'
|
||||
import type { GitRemote } from './parse.ts'
|
||||
import type { ForgeBranches, ForgeCheckout } from './types.ts'
|
||||
|
||||
/** Resolved Git executable and process limits. */
|
||||
export interface GitConfig {
|
||||
/** Git executable name or absolute path. */
|
||||
readonly gitCommand: string
|
||||
/** Deadline in milliseconds for one command, including executable lookup and confinement. */
|
||||
readonly gitTimeoutMs: number
|
||||
/** Termination grace period in milliseconds for each process. */
|
||||
readonly graceMs: number
|
||||
/** Maximum captured bytes per output stream. */
|
||||
readonly maxOutputBytes: number
|
||||
}
|
||||
|
||||
/** Checkout facts with the upstream and remotes used to recognize the code host. */
|
||||
export interface Inspection {
|
||||
/** Facts the panel shows. */
|
||||
readonly checkout: ForgeCheckout
|
||||
/** Canonical Git directory shared by every checkout of the repository. */
|
||||
readonly commonDir: string
|
||||
/** Upstream branch such as `origin/main`, or null when none is configured or HEAD is detached. */
|
||||
readonly upstream: string | null
|
||||
/** Remotes in listed order. */
|
||||
readonly remotes: readonly GitRemote[]
|
||||
}
|
||||
|
||||
interface GitOutput {
|
||||
readonly stdout: string
|
||||
readonly stderr: string
|
||||
readonly exitCode: number
|
||||
}
|
||||
|
||||
/** Runs one Git subcommand in a fixed directory; output with an exit code outside `allowedExitCodes` throws. */
|
||||
type Git = (name: string, args: readonly string[], allowedExitCodes?: readonly number[]) => Promise<GitOutput>
|
||||
|
||||
/**
|
||||
* Clear inherited repository, configuration, and replacement-ref selectors;
|
||||
* disable prompts, optional index writes, and lazy fetches of missing objects.
|
||||
* Git older than 2.45 ignores `GIT_NO_LAZY_FETCH`.
|
||||
*/
|
||||
const GIT_ENV: NodeJS.ProcessEnv = {
|
||||
...Object.fromEntries([
|
||||
'GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_OBJECT_DIRECTORY',
|
||||
'GIT_ALTERNATE_OBJECT_DIRECTORIES', 'GIT_CONFIG', 'GIT_CONFIG_PARAMETERS', 'GIT_CONFIG_COUNT',
|
||||
'GIT_CEILING_DIRECTORIES', 'GIT_PREFIX', 'GIT_SHALLOW_FILE', 'GIT_GRAFT_FILE',
|
||||
'GIT_REPLACE_REF_BASE', 'GIT_ATTR_SOURCE',
|
||||
].map(key => [key, undefined])),
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_OPTIONAL_LOCKS: '0',
|
||||
GIT_NO_LAZY_FETCH: '1',
|
||||
LC_ALL: 'C',
|
||||
}
|
||||
|
||||
/** Arguments before every subcommand: no index refresh writes, hooks, or filesystem monitor. */
|
||||
const GIT_PREFIX = ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false'] as const
|
||||
|
||||
const NOT_A_CHECKOUT = /not a git repository|must be run in a work tree/i
|
||||
|
||||
/**
|
||||
* Inspect the checkout containing `directory`. Every command runs through the
|
||||
* mounted subprocess provider under a read-only confinement of the Session's
|
||||
* sandbox policy and reads refs and configuration only, never the working
|
||||
* tree. The top-level and shared Git directories Git prints are resolved
|
||||
* through `ctx.fs` into canonical process paths, the form the
|
||||
* working-directory service records.
|
||||
* @param ctx - context that provides `fs`, `subprocess`, `sandbox`, and `sandboxPolicy`.
|
||||
* @param config - executable and process limits.
|
||||
* @param session - Session whose sandbox policy and execution world apply.
|
||||
* @param directory - absolute directory to inspect.
|
||||
* @param signal - cancellation of every command.
|
||||
* @returns the checkout facts, shared Git directory, upstream, and remotes, or undefined when `directory` is not inside a checkout.
|
||||
* @throws when Git cannot run, fails, times out, or prints more than `maxOutputBytes`.
|
||||
*/
|
||||
export async function inspectCheckout(
|
||||
ctx: Context,
|
||||
config: GitConfig,
|
||||
session: Session,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Inspection | undefined> {
|
||||
const git = gitIn(ctx, config, session, directory, signal)
|
||||
const top = await git('rev-parse', ['rev-parse', '--show-toplevel'], [0, 128])
|
||||
if (top.exitCode !== 0) {
|
||||
if (NOT_A_CHECKOUT.test(top.stderr)) return undefined
|
||||
throw new Error(`git rev-parse failed: ${firstLine(top.stderr)}`)
|
||||
}
|
||||
const canonical = async (path: string): Promise<string> => ctx.fs.processPath(await ctx.fs.resolve(path, { cwd: directory, signal }))
|
||||
const [root, commonDir, branch, upstream, remotes] = await Promise.all([
|
||||
canonical(parseTopLevel(top.stdout)),
|
||||
git('common-dir', ['rev-parse', '--path-format=absolute', '--git-common-dir']).then(output => canonical(parseTopLevel(output.stdout))),
|
||||
currentBranch(git),
|
||||
git('upstream', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], [0, 128]),
|
||||
git('remote', ['remote', '-v']),
|
||||
])
|
||||
return {
|
||||
checkout: { root, branch },
|
||||
commonDir,
|
||||
upstream: upstream.exitCode === 0 ? parseName(upstream.stdout) : null,
|
||||
remotes: parseRemotes(remotes.stdout),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List the local branches that can start a worktree of the checkout containing
|
||||
* `directory`, under the same confinement as {@link inspectCheckout}.
|
||||
* @param ctx - context that provides `subprocess`, `sandbox`, and `sandboxPolicy`.
|
||||
* @param config - executable and process limits.
|
||||
* @param session - Session whose sandbox policy and execution world apply.
|
||||
* @param directory - absolute directory inside the checkout.
|
||||
* @param limit - most branch names to return.
|
||||
* @param signal - cancellation of every command.
|
||||
* @returns the checked-out branch and local branches, the checked-out one first and then most recently committed first.
|
||||
* @throws when `directory` is not inside a checkout, or Git cannot run, fails, times out, or prints more than `maxOutputBytes`.
|
||||
*/
|
||||
export async function listBranches(
|
||||
ctx: Context,
|
||||
config: GitConfig,
|
||||
session: Session,
|
||||
directory: string,
|
||||
limit: number,
|
||||
signal: AbortSignal,
|
||||
): Promise<ForgeBranches> {
|
||||
const git = gitIn(ctx, config, session, directory, signal)
|
||||
const [current, refs] = await Promise.all([
|
||||
currentBranch(git),
|
||||
git('for-each-ref', ['for-each-ref', '--sort=-committerdate', `--count=${limit}`, '--format=%(refname:lstrip=2)', 'refs/heads/']),
|
||||
])
|
||||
return { current, branches: baseBranches(current, refs.stdout, limit) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Report whether a local branch exists, under the same confinement as {@link inspectCheckout}.
|
||||
* @param ctx - context that provides `subprocess`, `sandbox`, and `sandboxPolicy`.
|
||||
* @param config - executable and process limits.
|
||||
* @param session - Session whose sandbox policy and execution world apply.
|
||||
* @param repositoryRoot - absolute root of a checkout of the repository.
|
||||
* @param branch - local branch name.
|
||||
* @param signal - cancellation of the command.
|
||||
* @returns whether `refs/heads/<branch>` exists.
|
||||
* @throws when Git cannot run, fails, times out, or prints more than `maxOutputBytes`.
|
||||
*/
|
||||
export async function hasBranch(
|
||||
ctx: Context,
|
||||
config: GitConfig,
|
||||
session: Session,
|
||||
repositoryRoot: string,
|
||||
branch: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const git = gitIn(ctx, config, session, repositoryRoot, signal)
|
||||
const ref = await git('show-ref', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], [0, 1])
|
||||
return ref.exitCode === 0
|
||||
}
|
||||
|
||||
function gitIn(ctx: Context, config: GitConfig, session: Session, directory: string, signal: AbortSignal): Git {
|
||||
const policy: SandboxPolicy = { ...ctx.sandboxPolicy.resolve({ session, mode: 'read-only' }), mode: 'read-only' }
|
||||
return (name, args, allowedExitCodes = [0]) => runGit(ctx, config, policy, directory, name, args, signal, allowedExitCodes)
|
||||
}
|
||||
|
||||
/** Read the checked-out branch, including an unborn one; null when HEAD is detached. */
|
||||
async function currentBranch(git: Git): Promise<string | null> {
|
||||
const head = await git('symbolic-ref', ['symbolic-ref', '--quiet', '--short', 'HEAD'], [0, 1])
|
||||
return head.exitCode === 0 ? parseName(head.stdout) : null
|
||||
}
|
||||
|
||||
async function runGit(
|
||||
ctx: Context,
|
||||
config: GitConfig,
|
||||
policy: SandboxPolicy,
|
||||
cwd: string,
|
||||
name: string,
|
||||
args: readonly string[],
|
||||
signal: AbortSignal,
|
||||
allowedExitCodes: readonly number[],
|
||||
): Promise<GitOutput> {
|
||||
using timeout = deadline(signal, config.gitTimeoutMs, 'FORGE_PANEL_GIT_TIMEOUT')
|
||||
const program = await ctx.subprocess.resolveExecutable(config.gitCommand, undefined, timeout.signal)
|
||||
const confined = await ctx.sandbox.confine([program, ...GIT_PREFIX, ...args], policy, timeout.signal)
|
||||
timeout.signal.throwIfAborted()
|
||||
/* jscpd:ignore-start -- the bounded spawn-and-collect sequence matches the
|
||||
worktree service's runner; the two differ in policy, environment, and
|
||||
error text, and a shared helper would make this panel depend on the
|
||||
worktree package or add a subprocess API for two experimental callers. */
|
||||
const child = ctx.subprocess.spawn({
|
||||
argv: confined.argv,
|
||||
cwd,
|
||||
env: GIT_ENV,
|
||||
signal: timeout.signal,
|
||||
graceMs: config.graceMs,
|
||||
stdio: {
|
||||
stdin: 'ignore',
|
||||
stdout: { maxBytes: config.maxOutputBytes },
|
||||
stderr: { maxBytes: config.maxOutputBytes },
|
||||
},
|
||||
})
|
||||
try {
|
||||
const outcome = await child.done
|
||||
timeout.signal.throwIfAborted()
|
||||
// The spawn spec above collects both streams.
|
||||
const captured = child.collected as Required<SubprocessCollectedOutputs>
|
||||
const stdout = captured.stdout.readFrom(0)
|
||||
const stderr = captured.stderr.readFrom(0)
|
||||
if (outcome.exitCode === null || !allowedExitCodes.includes(outcome.exitCode)) {
|
||||
throw new Error(`git ${name} failed (${outcome.signal ?? outcome.exitCode}): ${firstLine(stderr.text)}`)
|
||||
}
|
||||
if (stdout.lossy) throw new Error(`git ${name} output exceeds maxOutputBytes`)
|
||||
return { stdout: stdout.text, stderr: stderr.text, exitCode: outcome.exitCode }
|
||||
} finally {
|
||||
child.terminate()
|
||||
await child.waitForExit()
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return text.split(/\r?\n/).map(line => line.trim()).find(line => line.length > 0) ?? 'no diagnostic'
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
/** Host Remote of the forge panel: checkout, code host, sign-in, change request, and worktree state per Session. */
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { AdmissionDecision } from '@deepseek-ai/dsh-agent-controller'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-worktree'
|
||||
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { listBranches } from './git.ts'
|
||||
import { missingWorktree, SessionPanel } from './panel.ts'
|
||||
import type { PanelConfig } from './panel.ts'
|
||||
import type { Config, ForgeBranches, ForgeMissingWorktree, ForgePanelState, ForgeSessionWorktree, ForgeWorktreeState } from './types.ts'
|
||||
import { forgeWorktreeProjection, refusal, WORKTREE_RECORD, WorktreeCreations } from './worktree.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Remote that serves the forge panel of the GUI. */
|
||||
forgePanel: ForgePanelController
|
||||
}
|
||||
}
|
||||
|
||||
/** Panel limits plus the branch listing limit and the worktree branch prefix. */
|
||||
type ControllerConfig = PanelConfig & { readonly maxBranches: number; readonly worktreePrefix: string }
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export class ForgePanelController extends TypertRemoteService {
|
||||
static inject = [
|
||||
'forge', 'forgeSession', 'sessionProjections', 'workingDirectory', 'fs', 'subprocess', 'sandbox', 'sandboxPolicy', 'agents',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
gitCommand: z.string().default('git'),
|
||||
gitTimeoutMs: z.number().default(10_000),
|
||||
graceMs: z.number().default(1_000),
|
||||
maxOutputBytes: z.number().default(1_048_576),
|
||||
readinessTimeoutMs: z.number().default(15_000),
|
||||
maxBranches: z.number().default(50),
|
||||
worktreePrefix: z.string().default('dsh/'),
|
||||
})
|
||||
|
||||
private readonly home: Context
|
||||
private readonly config: ControllerConfig
|
||||
private readonly panels = new Map<Session, SessionPanel>()
|
||||
private worktreeStates!: ProjectionHandle<ForgeWorktreeState>
|
||||
private creations!: WorktreeCreations
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
super(ctx, 'forgePanel')
|
||||
const resolved = config as Required<Config>
|
||||
if (resolved.gitCommand.trim().length === 0) throw new Error('forge-panel: gitCommand must not be empty')
|
||||
for (const key of ['gitTimeoutMs', 'graceMs', 'maxOutputBytes', 'readinessTimeoutMs'] as const) {
|
||||
const value = resolved[key]
|
||||
if (!Number.isSafeInteger(value) || value <= 0 || value > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`forge-panel: ${key} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
if (!Number.isSafeInteger(resolved.maxBranches) || resolved.maxBranches <= 0) {
|
||||
throw new Error('forge-panel: maxBranches must be a positive integer')
|
||||
}
|
||||
if (!branchPrefix(resolved.worktreePrefix)) throw new Error('forge-panel: worktreePrefix must start a valid Git branch name')
|
||||
this.home = ctx
|
||||
this.config = resolved
|
||||
ctx.effect(() => async () => {
|
||||
const panels = [...this.panels.values()]
|
||||
this.panels.clear()
|
||||
await Promise.allSettled(panels.map(panel => panel.dispose()))
|
||||
}, 'forge-panel: stop panels')
|
||||
}
|
||||
|
||||
protected async [Service.init](): Promise<void> {
|
||||
const ctx = this.home
|
||||
this.worktreeStates = await ctx.sessionProjections.register(forgeWorktreeProjection)
|
||||
// Admission, step proposals, and Session events read the worktree state, so they start once its reader is ready.
|
||||
this.creations = new WorktreeCreations(ctx, this.worktreeStates, this.config.worktreePrefix, (session) => {
|
||||
this.panels.get(session)?.updateWorktree()
|
||||
})
|
||||
ctx.on('agent-controller/admission', async ({ agent, signal }, next) => {
|
||||
const rejection = await this.admit(agent, signal)
|
||||
return rejection ?? await next()
|
||||
})
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
this.panels.get(session)?.observe(event)
|
||||
})
|
||||
ctx.on('internal/service', (name: string) => {
|
||||
if (name !== 'worktrees') return
|
||||
for (const panel of this.panels.values()) panel.updateWorktree()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ForgePanelState> {
|
||||
yield* this.panelOf(session).follow(signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<ForgeBranches> {
|
||||
return await listBranches(this.home, this.config, session, this.home.workingDirectory.get(session), this.config.maxBranches, signal)
|
||||
}
|
||||
|
||||
/* oxlint-disable typescript/require-await -- async keeps a refused or cancelled request a rejection, as Remote callers expect */
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
if (this.home.forge.changeRequestFromUrl(url) === undefined) {
|
||||
throw new RemoteError('forgePanel/unrecognized-url', 'No code host provider recognizes the change request URL', {})
|
||||
}
|
||||
this.home.forgeSession.track(session, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
this.home.forgeSession.dismiss(session, url)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
this.panels.get(session)?.refresh()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
if (from?.length === 0) throw new RemoteError('forgePanel/invalid-branch', 'The base branch name is empty', {})
|
||||
this.editableWorktree(session)
|
||||
this.creations.forget(session)
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
if (this.editableWorktree(session).request === null) return
|
||||
this.creations.forget(session)
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'cancel' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
this.creations.stop(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
const created = this.createdWorktree(session)
|
||||
const worktrees = this.home.get('worktrees')
|
||||
if (worktrees === undefined) throw new RemoteError('forgePanel/worktrees-unavailable', 'No worktree service is mounted', {})
|
||||
await this.maintain(session, async (agent, work) => {
|
||||
const operation = AbortSignal.any([signal, work])
|
||||
if (await this.home.fs.lstat(created.path, undefined, operation) !== undefined) return
|
||||
try {
|
||||
await worktrees.restore(agent, created, operation)
|
||||
} catch (error) {
|
||||
operation.throwIfAborted()
|
||||
throw new RemoteError('forgePanel/restore-failed', error instanceof Error ? error.message : String(error), {}, { cause: error })
|
||||
}
|
||||
})
|
||||
this.panels.get(session)?.reinspect()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
signal.throwIfAborted()
|
||||
const created = this.createdWorktree(session)
|
||||
await this.maintain(session, async (agent, work) => {
|
||||
await this.home.workingDirectory.set(agent, created.repositoryRoot, AbortSignal.any([signal, work]))
|
||||
})
|
||||
}
|
||||
|
||||
/* oxlint-enable typescript/require-await */
|
||||
|
||||
/**
|
||||
* Hold a message at Agent admission while the Session's requested worktree
|
||||
* is created, and reject it while the worktree the Session works in is gone.
|
||||
* A check that cannot answer lets the message pass.
|
||||
* @returns a decision that rejects the message, or undefined to pass it on.
|
||||
*/
|
||||
private async admit(agent: Agent, signal: AbortSignal): Promise<AdmissionDecision | undefined> {
|
||||
const session = agent.session
|
||||
const presented = this.panels.has(session)
|
||||
const state = this.worktreeState(session)
|
||||
if (state.request !== null && state.created === null && !state.locked) {
|
||||
return await this.creations.admit(agent, state.request.from, signal, presented)
|
||||
}
|
||||
const panel = this.panels.get(session)
|
||||
let missing: ForgeMissingWorktree | null
|
||||
try {
|
||||
missing = panel === undefined
|
||||
? await missingWorktree(this.home, this.config, session, state.created, this.home.workingDirectory.get(session), signal)
|
||||
: await panel.checkMissing(signal)
|
||||
} catch (_error) {
|
||||
// Without an answer from the filesystem or Git the panel offers no restoration, so the message passes.
|
||||
signal.throwIfAborted()
|
||||
return undefined
|
||||
}
|
||||
return missing === null
|
||||
? undefined
|
||||
: refusal('forge/worktree-missing', 'The worktree this Session works in was removed; restore it before sending', presented)
|
||||
}
|
||||
|
||||
private worktreeState(session: Session): ForgeWorktreeState {
|
||||
return this.worktreeStates.stateOf(session)
|
||||
}
|
||||
|
||||
private editableWorktree(session: Session): ForgeWorktreeState {
|
||||
if (this.home.get('worktrees') === undefined) {
|
||||
throw new RemoteError('forgePanel/worktrees-unavailable', 'No worktree service is mounted', {})
|
||||
}
|
||||
const state = this.worktreeState(session)
|
||||
if (state.locked || state.created !== null) {
|
||||
throw new RemoteError('forgePanel/session-started', 'The Session has started, so its worktree can no longer change', {})
|
||||
}
|
||||
// A creation records its worktree only for the request it started from.
|
||||
if (this.creations.of(session) !== null) throw new RemoteError('forgePanel/session-busy', 'The worktree is being created', {})
|
||||
return state
|
||||
}
|
||||
|
||||
private createdWorktree(session: Session): ForgeSessionWorktree {
|
||||
const created = this.worktreeState(session).created
|
||||
if (created === null) throw new RemoteError('forgePanel/no-worktree', 'The Session has no created worktree', {})
|
||||
return created
|
||||
}
|
||||
|
||||
/** Run work as maintenance of the Session's Agent, which refuses while the Agent has other work. */
|
||||
private async maintain(session: Session, work: (agent: Agent, signal: AbortSignal) => Promise<void>): Promise<void> {
|
||||
const agent = this.home.agents.get(session.id)
|
||||
if (agent === undefined) throw new RemoteError('forgePanel/session-busy', 'The Session is not loaded', {})
|
||||
let running: Promise<void>
|
||||
try {
|
||||
running = agent.runMaintenance(async (signal) => { await work(agent, signal) })
|
||||
} catch (error) {
|
||||
throw new RemoteError('forgePanel/session-busy', 'The Session is busy', {}, { cause: error })
|
||||
}
|
||||
await running
|
||||
}
|
||||
|
||||
private panelOf(session: Session): SessionPanel {
|
||||
let panel = this.panels.get(session)
|
||||
if (panel === undefined) {
|
||||
const created: SessionPanel = new SessionPanel(this.home, this.config, session, this.worktreeStates, this.creations, () => {
|
||||
if (this.panels.get(session) !== created) return
|
||||
this.panels.delete(session)
|
||||
void created.dispose()
|
||||
})
|
||||
panel = created
|
||||
this.panels.set(session, panel)
|
||||
}
|
||||
return panel
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `prefix` followed by hexadecimal digits is a branch name that Git
|
||||
* accepts: no leading `-` or `/`, no empty component, no component that
|
||||
* starts with `.` or ends with `.lock`, and none of `..`, `@{`, whitespace,
|
||||
* control characters, `~`, `^`, `:`, `?`, `*`, `[`, or `\`.
|
||||
* @param prefix - configured worktree branch prefix.
|
||||
* @returns whether every generated name passes `git check-ref-format --branch`.
|
||||
*/
|
||||
function branchPrefix(prefix: string): boolean {
|
||||
const name = `${prefix}0`
|
||||
if (name.startsWith('-') || name.includes('..') || name.includes('@{')) return false
|
||||
for (const char of name) {
|
||||
if (char <= ' ' || char === '\x7f' || '~^:?*[\\'.includes(char)) return false
|
||||
}
|
||||
return name.split('/').every(part => part.length > 0 && !part.startsWith('.') && !part.endsWith('.lock'))
|
||||
}
|
||||
|
||||
export default ForgePanelController
|
||||
@@ -0,0 +1,336 @@
|
||||
/** Live panel state of one followed Session, shared by all of its followers. */
|
||||
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-worktree'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { pluginRecordOf } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import type {} from '@deepseek-ai/dsh-working-directory'
|
||||
import { hasBranch, inspectCheckout } from './git.ts'
|
||||
import type { GitConfig, Inspection } from './git.ts'
|
||||
import { remoteOrder } from './parse.ts'
|
||||
import type {
|
||||
ForgeCheckoutState, ForgeMissingWorktree, ForgePanelChangeRequest, ForgePanelState, ForgePanelWorktree, ForgeSessionWorktree,
|
||||
ForgeWorktreeBlocker, ForgeWorktreeState,
|
||||
} from './types.ts'
|
||||
import { WORKTREE_RECORD } from './worktree.ts'
|
||||
import type { WorktreeCreations } from './worktree.ts'
|
||||
|
||||
/** Git limits plus the sign-in probe deadline. */
|
||||
export interface PanelConfig extends GitConfig {
|
||||
/** Deadline in milliseconds for one provider sign-in probe. */
|
||||
readonly readinessTimeoutMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel state of one Session. Construction starts the first checkout
|
||||
* inspection and watches the Session's change requests. Inspections run one at
|
||||
* a time; a request that arrives during one runs once more after it, and a
|
||||
* result for a directory the Session has since left is dropped. A sign-in
|
||||
* probe runs when an inspection recognizes a new code host and on `refresh`;
|
||||
* a probe result for a host the panel no longer shows is dropped. Each change
|
||||
* request keeps the latest snapshot a poll returned while later polls fail. The
|
||||
* worktree facts follow the `forgeWorktree` projection, the live creation, and the last failed one;
|
||||
* each inspection also decides whether the Session's sandbox can write the
|
||||
* checkout and its shared Git directory, which a new worktree needs, and
|
||||
* whether the worktree the Session created and works in is gone.
|
||||
*/
|
||||
export class SessionPanel {
|
||||
private current: ForgePanelState
|
||||
private readonly followers = new Set<() => void>()
|
||||
private readonly lifetime = new AbortController()
|
||||
private readonly tasks = new Set<Promise<void>>()
|
||||
private readonly stopWatching: () => void
|
||||
private readonly snapshots = new Map<string, ChangeRequestSnapshot>()
|
||||
private blocker: ForgeWorktreeBlocker | null = null
|
||||
private missing: ForgeMissingWorktree | null = null
|
||||
private inspecting = false
|
||||
private inspectAgain = false
|
||||
|
||||
/**
|
||||
* @param ctx - service context that provides the forge, Session, directory, and process services.
|
||||
* @param config - Git limits and the sign-in probe deadline.
|
||||
* @param session - Session the panel describes.
|
||||
* @param worktreeStates - reads the `forgeWorktree` projection of the Session.
|
||||
* @param creations - reads the running creation and the last failed one of the Session.
|
||||
* @param onIdle - called when the last follower leaves.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: PanelConfig,
|
||||
readonly session: Session,
|
||||
private readonly worktreeStates: ProjectionHandle<ForgeWorktreeState>,
|
||||
private readonly creations: Pick<WorktreeCreations, 'of' | 'failureOf'>,
|
||||
private readonly onIdle: () => void,
|
||||
) {
|
||||
this.current = {
|
||||
directory: ctx.workingDirectory.get(session),
|
||||
checkout: { state: 'pending' },
|
||||
repository: null,
|
||||
readiness: null,
|
||||
changeRequests: this.entries(ctx.forgeSession.changeRequests(session)),
|
||||
worktree: this.worktree(),
|
||||
}
|
||||
this.stopWatching = ctx.forgeSession.watch(session, (changeRequests) => {
|
||||
this.publish({ ...this.current, changeRequests: this.entries(changeRequests) })
|
||||
})
|
||||
this.inspect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the current state, then each later state, until `signal` aborts or the panel stops.
|
||||
* @param signal - ends this follower.
|
||||
* @returns the states in publication order; intermediate states may be skipped.
|
||||
*/
|
||||
async *follow(signal: AbortSignal): AsyncGenerator<ForgePanelState> {
|
||||
let wake: (() => void) | undefined
|
||||
const notify = (): void => { wake?.() }
|
||||
this.followers.add(notify)
|
||||
signal.addEventListener('abort', notify)
|
||||
this.lifetime.signal.addEventListener('abort', notify)
|
||||
try {
|
||||
let delivered: ForgePanelState | undefined
|
||||
while (!signal.aborted && !this.lifetime.signal.aborted) {
|
||||
if (this.current !== delivered) {
|
||||
delivered = this.current
|
||||
yield delivered
|
||||
continue
|
||||
}
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
wake = undefined
|
||||
}
|
||||
} finally {
|
||||
this.followers.delete(notify)
|
||||
signal.removeEventListener('abort', notify)
|
||||
this.lifetime.signal.removeEventListener('abort', notify)
|
||||
if (this.followers.size === 0) this.onIdle()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React to one committed event of the Session: a directory change moves the
|
||||
* panel and reinspects; a tool result reinspects, because the tool may have
|
||||
* changed the checkout; a sandbox mode change reinspects, because it decides
|
||||
* whether a worktree can be created; the first turn, the first step, and
|
||||
* worktree records update the worktree facts.
|
||||
* @param event - committed Session event.
|
||||
*/
|
||||
observe(event: SessionEvent): void {
|
||||
if (event.type === 'working-directory/change') {
|
||||
this.publish({ ...this.current, directory: event.data.cwd, checkout: { state: 'pending' } })
|
||||
this.inspect()
|
||||
} else if (event.type === 'tool/result' || event.type === 'sandbox/mode') {
|
||||
this.inspect()
|
||||
} else if (event.type === 'turn/start' || event.type === 'step/start' || pluginRecordOf(event)?.type === WORKTREE_RECORD) {
|
||||
this.updateWorktree()
|
||||
}
|
||||
}
|
||||
|
||||
/** Republish the worktree facts when they changed, such as after a creation moved or a worktree service appeared or left. */
|
||||
updateWorktree(): void {
|
||||
const worktree = this.worktree()
|
||||
if (!sameWorktree(worktree, this.current.worktree)) this.publish({ ...this.current, worktree })
|
||||
}
|
||||
|
||||
/** Reinspect the checkout, such as after its directory was created again. */
|
||||
reinspect(): void {
|
||||
this.inspect()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check again whether the worktree the Session works in is gone, publishing a change.
|
||||
* @param signal - cancellation of the check.
|
||||
* @returns the missing worktree, or null while it exists or the Session works elsewhere.
|
||||
* @throws when the filesystem or Git cannot answer.
|
||||
*/
|
||||
async checkMissing(signal: AbortSignal): Promise<ForgeMissingWorktree | null> {
|
||||
const directory = this.current.directory
|
||||
const created = this.worktreeStates.stateOf(this.session).created
|
||||
const missing = await missingWorktree(
|
||||
this.ctx, this.config, this.session, created, directory, AbortSignal.any([signal, this.lifetime.signal]),
|
||||
)
|
||||
if (this.current.directory === directory) {
|
||||
this.missing = missing
|
||||
this.updateWorktree()
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
/** Reinspect the checkout, probe sign-in again, and poll every bound change request at once. */
|
||||
refresh(): void {
|
||||
this.inspect()
|
||||
if (this.current.repository !== null) this.probe(this.current.repository)
|
||||
for (const entry of this.current.changeRequests) {
|
||||
if (entry.ref !== null) this.ctx.forge.refresh(entry.ref)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop inspections, probes, and the change request watch, end every follower, and wait for pending work.
|
||||
* @returns settles once pending inspections and probes have ended.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
this.lifetime.abort()
|
||||
this.stopWatching()
|
||||
await Promise.allSettled(this.tasks)
|
||||
}
|
||||
|
||||
private entries(changeRequests: readonly SessionChangeRequest[]): ForgePanelChangeRequest[] {
|
||||
return changeRequests.map((entry) => {
|
||||
if (entry.update !== null && 'snapshot' in entry.update) this.snapshots.set(entry.url, entry.update.snapshot)
|
||||
return { ...entry, snapshot: this.snapshots.get(entry.url) ?? null }
|
||||
})
|
||||
}
|
||||
|
||||
private worktree(): ForgePanelWorktree {
|
||||
const state = this.worktreeStates.stateOf(this.session)
|
||||
return {
|
||||
editable: this.ctx.get('worktrees') !== undefined && !state.locked && state.created === null,
|
||||
blocker: this.blocker,
|
||||
request: state.request,
|
||||
created: state.created,
|
||||
creation: this.creations.of(this.session),
|
||||
failure: this.creations.failureOf(this.session),
|
||||
missing: this.missing,
|
||||
}
|
||||
}
|
||||
|
||||
private publish(next: ForgePanelState): void {
|
||||
this.current = next
|
||||
for (const notify of [...this.followers]) notify()
|
||||
}
|
||||
|
||||
private run(task: Promise<void>): void {
|
||||
this.tasks.add(task)
|
||||
const settle = (): void => { this.tasks.delete(task) }
|
||||
void task.then(settle, settle)
|
||||
}
|
||||
|
||||
private inspect(): void {
|
||||
if (this.lifetime.signal.aborted) return
|
||||
if (this.inspecting) {
|
||||
this.inspectAgain = true
|
||||
return
|
||||
}
|
||||
this.inspecting = true
|
||||
this.run(this.inspectOnce().finally(() => {
|
||||
this.inspecting = false
|
||||
if (!this.inspectAgain) return
|
||||
this.inspectAgain = false
|
||||
this.inspect()
|
||||
}))
|
||||
}
|
||||
|
||||
private async inspectOnce(): Promise<void> {
|
||||
const directory = this.current.directory
|
||||
let checkout: ForgeCheckoutState
|
||||
let repository: ForgeRepository | null = null
|
||||
let blocker: ForgeWorktreeBlocker | null = null
|
||||
try {
|
||||
const inspection = await inspectCheckout(this.ctx, this.config, this.session, directory, this.lifetime.signal)
|
||||
if (inspection === undefined) {
|
||||
checkout = { state: 'none' }
|
||||
blocker = 'not-git'
|
||||
} else {
|
||||
checkout = { state: 'ready', checkout: inspection.checkout }
|
||||
repository = this.recognize(inspection)
|
||||
blocker = await this.blockerOf(inspection)
|
||||
}
|
||||
} catch (error) {
|
||||
checkout = { state: 'failed', message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
let missing: ForgeMissingWorktree | null = null
|
||||
try {
|
||||
const created = this.worktreeStates.stateOf(this.session).created
|
||||
missing = await missingWorktree(this.ctx, this.config, this.session, created, directory, this.lifetime.signal)
|
||||
} catch (_error) {
|
||||
// Without an answer from the filesystem or Git the panel offers no restoration.
|
||||
}
|
||||
if (this.lifetime.signal.aborted || this.current.directory !== directory) return
|
||||
this.blocker = blocker
|
||||
this.missing = missing
|
||||
const sameHost = repository !== null && this.current.repository !== null && hostOf(repository) === hostOf(this.current.repository)
|
||||
this.publish({ ...this.current, checkout, repository, readiness: sameHost ? this.current.readiness : null, worktree: this.worktree() })
|
||||
if (repository !== null && !sameHost) this.probe(repository)
|
||||
}
|
||||
|
||||
/** Decide whether the Session's sandbox lets the worktree service write the checkout and its shared Git directory. */
|
||||
private async blockerOf(inspection: Inspection): Promise<ForgeWorktreeBlocker | null> {
|
||||
const policy = this.ctx.sandboxPolicy.resolve({ session: this.session })
|
||||
if (policy.mode === 'danger-full-access') return null
|
||||
if (policy.mode === 'read-only') return 'read-only'
|
||||
const root = this.ctx.fs.processPath(await this.ctx.fs.resolve(policy.workspaceRoot, { signal: this.lifetime.signal }))
|
||||
return within(root, inspection.checkout.root) && within(root, inspection.commonDir) ? null : 'outside-writable-root'
|
||||
}
|
||||
|
||||
private recognize(inspection: Inspection): ForgeRepository | null {
|
||||
for (const remote of remoteOrder(inspection.upstream, inspection.remotes)) {
|
||||
for (const url of remote.urls) {
|
||||
const repository = this.ctx.forge.repositoryFromRemote(url)
|
||||
if (repository !== undefined) return repository
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private probe(repository: ForgeRepository): void {
|
||||
const host = hostOf(repository)
|
||||
this.run((async () => {
|
||||
let readiness: ForgeReadiness
|
||||
try {
|
||||
using timeout = deadline(this.lifetime.signal, this.config.readinessTimeoutMs, 'FORGE_PANEL_READINESS_TIMEOUT')
|
||||
readiness = await this.ctx.forge.readiness(repository, timeout.signal)
|
||||
} catch (error) {
|
||||
readiness = { state: 'unavailable', message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
if (this.lifetime.signal.aborted || this.current.repository === null || hostOf(this.current.repository) !== host) return
|
||||
this.publish({ ...this.current, readiness })
|
||||
})())
|
||||
}
|
||||
}
|
||||
|
||||
function sameWorktree(left: ForgePanelWorktree, right: ForgePanelWorktree): boolean {
|
||||
return left.editable === right.editable && left.blocker === right.blocker && left.request === right.request
|
||||
&& left.created === right.created && left.creation === right.creation && left.failure === right.failure
|
||||
&& left.missing?.branchExists === right.missing?.branchExists
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether a Session works inside the worktree it created while that
|
||||
* worktree's directory is gone, and whether its branch still exists.
|
||||
* @param ctx - context that provides `fs` and the process services.
|
||||
* @param config - Git limits.
|
||||
* @param session - Session to check.
|
||||
* @param created - the worktree the Session created, or null.
|
||||
* @param directory - the Session's current directory.
|
||||
* @param signal - cancellation of the check.
|
||||
* @returns the missing worktree, or null while it exists or the Session works elsewhere.
|
||||
* @throws when the filesystem or Git cannot answer.
|
||||
*/
|
||||
export async function missingWorktree(
|
||||
ctx: Context,
|
||||
config: GitConfig,
|
||||
session: Session,
|
||||
created: ForgeSessionWorktree | null,
|
||||
directory: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<ForgeMissingWorktree | null> {
|
||||
if (created === null || !within(created.path, directory)) return null
|
||||
if (await ctx.fs.lstat(created.path, undefined, signal) !== undefined) return null
|
||||
return { branchExists: await hasBranch(ctx, config, session, created.repositoryRoot, created.branch, signal) }
|
||||
}
|
||||
|
||||
/** Whether `path` is `root` or lies below it. */
|
||||
function within(root: string, path: string): boolean {
|
||||
const rest = relative(root, path)
|
||||
return rest === '' || (rest !== '..' && !rest.startsWith(`..${sep}`) && !isAbsolute(rest))
|
||||
}
|
||||
|
||||
function hostOf(repository: ForgeRepository): string {
|
||||
return `${repository.forge}:${repository.host}`
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** Parsers for the porcelain output of the read-only Git commands the panel runs. */
|
||||
|
||||
/** One remote with the URLs `git remote -v` prints for it. */
|
||||
export interface GitRemote {
|
||||
/** Remote name such as `origin`. */
|
||||
readonly name: string
|
||||
/** Distinct URLs, fetch URL first. */
|
||||
readonly urls: readonly string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first line that `git rev-parse` prints for a directory query such as `--show-toplevel`.
|
||||
* @param output - stdout of the command.
|
||||
* @returns the directory.
|
||||
* @throws when the output is empty.
|
||||
*/
|
||||
export function parseTopLevel(output: string): string {
|
||||
const [root = ''] = output.split(/\r?\n/)
|
||||
if (root.length === 0) throw new Error('git rev-parse printed no directory')
|
||||
return root
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the first line of a one-name output such as `git symbolic-ref --short HEAD`.
|
||||
* @param output - stdout of the command.
|
||||
* @returns the name, or null when the command printed none.
|
||||
*/
|
||||
export function parseName(output: string): string | null {
|
||||
const [name = ''] = output.split(/\r?\n/)
|
||||
return name.length === 0 ? null : name
|
||||
}
|
||||
|
||||
/**
|
||||
* Order worktree base branches: the checked-out branch first, then the other
|
||||
* listed branches in listed order.
|
||||
* @param current - checked-out branch, or null when HEAD is detached.
|
||||
* @param output - stdout of `git for-each-ref --format=%(refname:lstrip=2) refs/heads/`.
|
||||
* @param limit - most names to return.
|
||||
* @returns distinct branch names, at most `limit`.
|
||||
*/
|
||||
export function baseBranches(current: string | null, output: string, limit: number): string[] {
|
||||
const listed = output.split(/\r?\n/).filter(name => name.length > 0 && name !== current)
|
||||
return (current === null ? listed : [current, ...listed]).slice(0, limit)
|
||||
}
|
||||
|
||||
/**
|
||||
* Group the lines of `git remote -v` by remote.
|
||||
* @param output - stdout of the command.
|
||||
* @returns remotes in first-listed order.
|
||||
*/
|
||||
export function parseRemotes(output: string): GitRemote[] {
|
||||
const remotes = new Map<string, string[]>()
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const [, name = '', url = '', direction = ''] = /^(\S+)\t(.+) \((fetch|push)\)$/.exec(line) ?? []
|
||||
if (name.length === 0) continue
|
||||
let urls = remotes.get(name)
|
||||
if (urls === undefined) {
|
||||
urls = []
|
||||
remotes.set(name, urls)
|
||||
}
|
||||
if (urls.includes(url)) continue
|
||||
if (direction === 'fetch') urls.unshift(url)
|
||||
else urls.push(url)
|
||||
}
|
||||
return [...remotes].map(([name, urls]) => ({ name, urls }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Order remotes for code host recognition: the upstream's remote first, then
|
||||
* `origin`, then the rest in listed order. The upstream's remote is the
|
||||
* longest remote name followed by `/` at the start of the upstream branch.
|
||||
* @param upstream - upstream branch such as `origin/main`, or null.
|
||||
* @param remotes - remotes in listed order.
|
||||
* @returns the same remotes in recognition order.
|
||||
*/
|
||||
export function remoteOrder(upstream: string | null, remotes: readonly GitRemote[]): GitRemote[] {
|
||||
const tracked = upstream === null
|
||||
? undefined
|
||||
: remotes
|
||||
.filter(remote => upstream.startsWith(`${remote.name}/`))
|
||||
.reduce<GitRemote | undefined>(
|
||||
(longest, remote) => longest === undefined || remote.name.length > longest.name.length ? remote : longest,
|
||||
undefined,
|
||||
)
|
||||
const rank = (remote: GitRemote): number => remote === tracked ? 0 : remote.name === 'origin' ? 1 : 2
|
||||
return [...remotes].sort((left, right) => rank(left) - rank(right))
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
/** Browser-safe panel state, the worktree request record, Remote error codes, and Host configuration of the forge panel. */
|
||||
|
||||
import type {} from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { ChangeRequestSnapshot, ForgeReadiness, ForgeRepository } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session/types'
|
||||
|
||||
/** Git facts for the checkout that contains the Session's current directory. */
|
||||
export interface ForgeCheckout {
|
||||
/** Top-level directory of the checkout. */
|
||||
readonly root: string
|
||||
/** Checked-out branch, or null when HEAD is detached. */
|
||||
readonly branch: string | null
|
||||
}
|
||||
|
||||
/** Inspection result for the Session's current directory. */
|
||||
export 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 }
|
||||
|
||||
/** The worktree the panel created and entered for a Session before its first step. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/** Payload of the `plugin:client-ui-forge/worktree` record. */
|
||||
export 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 }
|
||||
|
||||
/** One open worktree request. */
|
||||
export interface ForgeWorktreeRequest {
|
||||
/** Base branch, or null for HEAD of the source checkout. */
|
||||
readonly from: string | null
|
||||
}
|
||||
|
||||
/** Host-only state of the `forgeWorktree` projection. */
|
||||
export interface ForgeWorktreeState {
|
||||
/** Open request, or null without one. */
|
||||
readonly request: ForgeWorktreeRequest | null
|
||||
/** Created worktree, or null before creation. */
|
||||
readonly created: ForgeSessionWorktree | null
|
||||
/** Whether a turn has started; requests and withdrawals recorded afterwards are ignored. */
|
||||
readonly locked: boolean
|
||||
/** Whether a step has started; no worktree is created afterwards. */
|
||||
readonly stepped: boolean
|
||||
}
|
||||
|
||||
/** Why the Session's checkout cannot host a new worktree. */
|
||||
export 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'
|
||||
|
||||
/** One running worktree creation. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/** The last attempt to create the requested worktree for a message, which failed. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export 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'
|
||||
|
||||
/** The worktree a Session works in, whose directory no longer exists. */
|
||||
export interface ForgeMissingWorktree {
|
||||
/** Whether the worktree's branch still exists; restoring creates a missing branch again at its base commit. */
|
||||
readonly branchExists: boolean
|
||||
}
|
||||
|
||||
/** Worktree facts of one Session in the panel state. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/** One bound change request in the panel state. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/** Everything the panel shows for one Session. */
|
||||
export 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
|
||||
}
|
||||
|
||||
/** Local branches offered as the base of a new worktree. */
|
||||
export 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[]
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-typert-protocol' {
|
||||
interface RemoteErrorDetailsMap {
|
||||
/** No registered provider serves the change request URL. */
|
||||
'forgePanel/unrecognized-url': Record<string, never>
|
||||
/** A turn of the Session has started, or its worktree was created, so its worktree request can no longer change. */
|
||||
'forgePanel/session-started': Record<string, never>
|
||||
/** No worktree service is mounted. */
|
||||
'forgePanel/worktrees-unavailable': Record<string, never>
|
||||
/** The requested base branch name is empty. */
|
||||
'forgePanel/invalid-branch': Record<string, never>
|
||||
/** The Session has no created worktree to restore or leave. */
|
||||
'forgePanel/no-worktree': Record<string, never>
|
||||
/** The Session's Agent is not loaded or has other work, so its checkout cannot change now. */
|
||||
'forgePanel/session-busy': Record<string, never>
|
||||
/** The removed worktree could not be checked out again; the message gives the reason. */
|
||||
'forgePanel/restore-failed': Record<string, never>
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-session/types' {
|
||||
interface PluginRecordMap {
|
||||
/** A request, withdrawal, or creation of the worktree a Session starts in. */
|
||||
'plugin:client-ui-forge/worktree': WorktreeRecord
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/** The worktree a Session requests before it starts: its record, its projection, and its creation. */
|
||||
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import type { AdmissionDecision } from '@deepseek-ai/dsh-agent-controller'
|
||||
import type { CreatedWorktree, CreateWorktreePhase } from '@deepseek-ai/dsh-experimental-worktree'
|
||||
import { appendPluginRecord, pluginRecordOf } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import { assertNever } from '@deepseek-ai/dsh-util-values'
|
||||
import { z } from 'zod'
|
||||
import type {
|
||||
ForgeAdmissionRefusal, ForgeSessionWorktree, ForgeWorktreeCreation, ForgeWorktreeFailure, ForgeWorktreeState, WorktreeRecord,
|
||||
} from './types.ts'
|
||||
|
||||
/** Plugin record type of worktree requests, withdrawals, and creations. */
|
||||
export const WORKTREE_RECORD = 'plugin:client-ui-forge/worktree'
|
||||
|
||||
const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false }
|
||||
|
||||
const worktreeSchema: z.ZodType<ForgeSessionWorktree> = z.object({
|
||||
path: z.string().min(1),
|
||||
branch: z.string().min(1),
|
||||
from: z.string().min(1).nullable(),
|
||||
baseCommit: z.string().min(1),
|
||||
repositoryRoot: z.string().min(1),
|
||||
}).strict()
|
||||
|
||||
const recordSchema: z.ZodType<WorktreeRecord> = z.discriminatedUnion('action', [
|
||||
z.object({ action: z.literal('request'), from: z.string().min(1).nullable() }).strict(),
|
||||
z.object({ action: z.literal('cancel') }).strict(),
|
||||
z.object({ action: z.literal('created'), worktree: worktreeSchema }).strict(),
|
||||
])
|
||||
|
||||
const stateSchema: z.ZodType<ForgeWorktreeState> = z.object({
|
||||
request: z.object({ from: z.string().min(1).nullable() }).strict().nullable(),
|
||||
created: worktreeSchema.nullable(),
|
||||
locked: z.boolean(),
|
||||
stepped: z.boolean(),
|
||||
}).strict()
|
||||
|
||||
/**
|
||||
* The host-only `forgeWorktree` unit. `request` and `cancel` records change
|
||||
* the request until the first `turn/start` or creation; the first `created`
|
||||
* record of an open request applies until the first `step/start`. Records
|
||||
* outside those periods leave the state unchanged.
|
||||
*/
|
||||
export const forgeWorktreeProjection = {
|
||||
key: 'forgeWorktree',
|
||||
stateVersion: 2,
|
||||
stateSchema,
|
||||
init: () => EMPTY,
|
||||
apply,
|
||||
} satisfies ProjectionDefinition<'forgeWorktree', ForgeWorktreeState>
|
||||
|
||||
function apply(state: ForgeWorktreeState, event: SessionEvent): ForgeWorktreeState {
|
||||
switch (event.type) {
|
||||
case 'turn/start':
|
||||
return state.locked ? state : { ...state, locked: true }
|
||||
case 'step/start':
|
||||
return state.stepped ? state : { ...state, stepped: true }
|
||||
// Every other event type, including plugin records, which sit outside SessionEventMap.
|
||||
default:
|
||||
return applyRecord(state, event)
|
||||
}
|
||||
}
|
||||
|
||||
function applyRecord(state: ForgeWorktreeState, event: SessionEvent): ForgeWorktreeState {
|
||||
const record = pluginRecordOf(event)
|
||||
if (record?.type !== WORKTREE_RECORD) return state
|
||||
const parsed = recordSchema.safeParse(record.data)
|
||||
if (!parsed.success) return state
|
||||
const data = parsed.data
|
||||
const settled = state.created !== null
|
||||
switch (data.action) {
|
||||
case 'request':
|
||||
return state.locked || settled ? state : { ...state, request: { from: data.from } }
|
||||
case 'cancel':
|
||||
return state.locked || settled || state.request === null ? state : { ...state, request: null }
|
||||
case 'created':
|
||||
return settled || state.stepped || state.request === null ? state : { ...state, created: data.worktree }
|
||||
/* v8 ignore next -- closed WorktreeRecord action union exhaustiveness guard */
|
||||
default: return assertNever(data, 'WorktreeRecord.action')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject a message at Agent admission.
|
||||
* @param reason - why the panel rejects it.
|
||||
* @param message - text the sender shows when no panel does.
|
||||
* @param presented - whether a panel shows the rejection to the sender.
|
||||
* @returns the decision that keeps the message out of the inbox.
|
||||
*/
|
||||
export function refusal(reason: ForgeAdmissionRefusal, message: string, presented: boolean): AdmissionDecision {
|
||||
return { kind: 'reject', reason, message, presented }
|
||||
}
|
||||
|
||||
/** A failed attempt to create a requested worktree; `cause` holds the worktree service's error. */
|
||||
export class CreationFailure extends Error {
|
||||
/**
|
||||
* @param branch - branch the attempt created.
|
||||
* @param phase - step that failed.
|
||||
* @param reason - the worktree service's failure text.
|
||||
* @param cause - the worktree service's error.
|
||||
*/
|
||||
constructor(readonly branch: string, readonly phase: CreateWorktreePhase, readonly reason: string, cause: unknown) {
|
||||
super(`The requested worktree ${branch} could not be created`, { cause })
|
||||
this.name = 'CreationFailure'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates requested worktrees, one attempt with a new branch name per call:
|
||||
* through {@link WorktreeCreations.admit}, which holds a Session's first
|
||||
* message at Agent admission until its worktree exists, and at the first step
|
||||
* proposal of a Session whose request is still open, which covers a message
|
||||
* that entered without admission. The worktree service enters the created
|
||||
* checkout and queues its directory notice. At a step proposal the notice
|
||||
* joins the proposed messages so the first request already names the new
|
||||
* directory; a proposal without messages leaves it queued, and a failure ends
|
||||
* the turn with the request still open. A failed attempt may leave its branch
|
||||
* or checkout behind.
|
||||
*/
|
||||
export class WorktreeCreations {
|
||||
private readonly live = new Map<Session, ForgeWorktreeCreation>()
|
||||
private readonly failures = new Map<Session, ForgeWorktreeFailure>()
|
||||
private readonly stops = new Map<Session, AbortController>()
|
||||
|
||||
/**
|
||||
* @param ctx - context that optionally provides `worktrees`.
|
||||
* @param states - reads the `forgeWorktree` projection of a Session.
|
||||
* @param prefix - branch name prefix; eight random hexadecimal digits follow it.
|
||||
* @param changed - called after the creation of a Session starts, changes phase, or ends, and after its failure changes.
|
||||
*/
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly states: ProjectionHandle<ForgeWorktreeState>,
|
||||
private readonly prefix: string,
|
||||
private readonly changed: (session: Session) => void,
|
||||
) {
|
||||
ctx.on('agent/pre-step', async ({ agent, step, signal }, next): Promise<PreStepDecision> => await this.preStep(agent, step, signal, next))
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the running creation of a Session.
|
||||
* @param session - Session to read.
|
||||
* @returns the running creation, or null.
|
||||
*/
|
||||
of(session: Session): ForgeWorktreeCreation | null {
|
||||
return this.live.get(session) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the failed attempt that returned a Session's last message to its sender.
|
||||
* @param session - Session to read.
|
||||
* @returns the failure, or null.
|
||||
*/
|
||||
failureOf(session: Session): ForgeWorktreeFailure | null {
|
||||
return this.failures.get(session) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Forget the failure of a Session whose request changes or is withdrawn; the record of that change republishes the panel.
|
||||
* @param session - Session whose failure no longer applies.
|
||||
*/
|
||||
forget(session: Session): void {
|
||||
this.failures.delete(session)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Session's requested worktree for a message that waits at Agent
|
||||
* admission, as maintenance of the Session's Agent. A failure is kept for
|
||||
* {@link WorktreeCreations.failureOf} until another attempt starts or the
|
||||
* request changes; the message then goes back to its sender, which may send
|
||||
* it again for another attempt. {@link WorktreeCreations.stop} and the
|
||||
* Agent's cancellation also return the message.
|
||||
* @param agent - Agent of the Session.
|
||||
* @param from - base branch, or null for HEAD of the current checkout.
|
||||
* @param signal - the sender's cancellation, whose reason passes through unchanged.
|
||||
* @param presented - whether a panel shows a rejection to the sender.
|
||||
* @returns undefined once the worktree exists, or a decision that rejects the message with
|
||||
* `forge/worktree-creation-failed` or `forge/worktree-creation-stopped`.
|
||||
* @throws the sender's cancellation reason once `signal` aborts.
|
||||
*/
|
||||
async admit(agent: Agent, from: string | null, signal: AbortSignal, presented: boolean): Promise<AdmissionDecision | undefined> {
|
||||
const session = agent.session
|
||||
const stop = new AbortController()
|
||||
let work: AbortSignal | undefined
|
||||
this.stops.set(session, stop)
|
||||
try {
|
||||
await agent.runMaintenance(async (maintenance) => {
|
||||
work = maintenance
|
||||
await this.create(agent, from, AbortSignal.any([signal, stop.signal, maintenance]), true)
|
||||
})
|
||||
} catch (error) {
|
||||
signal.throwIfAborted()
|
||||
if (stop.signal.aborted || work?.aborted === true) return refusal('forge/worktree-creation-stopped', 'The worktree creation was stopped', presented)
|
||||
const failure: ForgeWorktreeFailure = error instanceof CreationFailure
|
||||
? { branch: error.branch, from, phase: error.phase, message: error.reason }
|
||||
: { branch: null, from, phase: 'prepare', message: error instanceof Error ? error.message : String(error) }
|
||||
this.failures.set(session, failure)
|
||||
this.changed(session)
|
||||
return refusal('forge/worktree-creation-failed', `The worktree could not be created: ${failure.message}`, presented)
|
||||
} finally {
|
||||
this.stops.delete(session)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the creation that holds a message of a 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.
|
||||
*/
|
||||
stop(session: Session): void {
|
||||
this.stops.get(session)?.abort(new Error('The worktree creation was stopped'))
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Session's requested worktree with a new branch name, enter it, and
|
||||
* record it on the Session. The running creation is readable through
|
||||
* {@link WorktreeCreations.of} until the record exists or the attempt fails.
|
||||
* @param agent - Agent of the Session.
|
||||
* @param from - base branch, or null for HEAD of the current checkout.
|
||||
* @param signal - cancellation of the attempt.
|
||||
* @param held - whether a message waits for the creation at admission.
|
||||
* @returns the created worktree.
|
||||
* @throws {@link CreationFailure} when the attempt fails; the cancellation reason once `signal` aborts.
|
||||
*/
|
||||
async create(agent: Agent, from: string | null, signal: AbortSignal, held: boolean): Promise<CreatedWorktree> {
|
||||
const worktrees = this.ctx.get('worktrees')
|
||||
if (worktrees === undefined) throw new Error('No worktree service is mounted')
|
||||
const session = agent.session
|
||||
let status: ForgeWorktreeCreation = { phase: 'prepare', branch: `${this.prefix}${randomBytes(4).toString('hex')}`, from, held }
|
||||
this.failures.delete(session)
|
||||
this.live.set(session, status)
|
||||
this.changed(session)
|
||||
try {
|
||||
let created: CreatedWorktree
|
||||
try {
|
||||
created = await worktrees.create(agent, { name: status.branch, ...from === null ? {} : { from } }, signal, (phase) => {
|
||||
status = { ...status, phase }
|
||||
this.live.set(session, status)
|
||||
this.changed(session)
|
||||
})
|
||||
} catch (error) {
|
||||
signal.throwIfAborted()
|
||||
throw new CreationFailure(status.branch, status.phase, error instanceof Error ? error.message : String(error), error)
|
||||
}
|
||||
const worktree: ForgeSessionWorktree = {
|
||||
path: created.path, branch: created.branch, from, baseCommit: created.baseCommit, repositoryRoot: created.repositoryRoot,
|
||||
}
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree })
|
||||
return created
|
||||
} finally {
|
||||
// After the record, so a follower never sees the creation end without its result.
|
||||
this.live.delete(session)
|
||||
this.changed(session)
|
||||
}
|
||||
}
|
||||
|
||||
private async preStep(agent: Agent, step: number, signal: AbortSignal, next: () => Promise<PreStepDecision>): Promise<PreStepDecision> {
|
||||
const state = this.states.stateOf(agent.session)
|
||||
if (step !== 1 || state.request === null || state.created !== null || state.stepped) return await next()
|
||||
await this.create(agent, state.request.from, signal, false)
|
||||
const decision = await next()
|
||||
if (decision.kind === 'reject' || decision.messages.length === 0) return decision
|
||||
const notices = agent.inbox.nextStep.filter(message => message.source.kind === 'working-directory')
|
||||
for (const notice of notices) agent.inbox.remove(notice.id)
|
||||
return notices.length === 0 ? decision : { ...decision, messages: [...decision.messages, ...notices] }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Session header branch label: when it shows, the worktree a Session started in, its removal, and the current branch. */
|
||||
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { BranchChip } from '../src/client/BranchChip.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { PanelSnapshot } from '../src/client/source.ts'
|
||||
import type { ForgePanelState } from '../src/types.ts'
|
||||
import { created, ready, removed, worktree } from './client-fixtures.client.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(en)
|
||||
|
||||
function chip(state: ForgePanelState | null) {
|
||||
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: false })
|
||||
const view = render(<BranchChip usePanel={bindSnapshotSelector(panel)} t={t} />)
|
||||
const label = (): HTMLElement => {
|
||||
const element = view.container.querySelector('[data-forge-branch]')
|
||||
if (!(element instanceof HTMLElement)) throw new Error('no branch label')
|
||||
return element
|
||||
}
|
||||
const publish = (next: ForgePanelState): void => {
|
||||
act(() => { panel.set({ state: next, disconnected: false }) })
|
||||
}
|
||||
return { view, label, publish }
|
||||
}
|
||||
|
||||
/** The tooltip of the label after the pointer enters it. */
|
||||
function detail(label: HTMLElement): string | null {
|
||||
fireEvent.mouseEnter(label)
|
||||
return screen.getByRole('tooltip').textContent
|
||||
}
|
||||
|
||||
/** Panel state of a Session that works in the worktree it created at `/work/repo-dsh`. */
|
||||
function inWorktree(made = created()): ForgePanelState {
|
||||
return ready({ directory: '/work/repo-dsh', worktree: worktree({ created: made }) }, { root: '/work/repo-dsh', branch: 'other' })
|
||||
}
|
||||
|
||||
describe('BranchChip visibility', () => {
|
||||
it.each([
|
||||
['before the first state', null],
|
||||
['while the first inspection runs', ready({ checkout: { state: 'pending' } })],
|
||||
['outside a Git checkout', ready({ checkout: { state: 'none' } })],
|
||||
['when the inspection failed', ready({ checkout: { state: 'failed', message: 'git status failed' } })],
|
||||
['on a detached HEAD', ready({}, { branch: null })],
|
||||
['on a detached HEAD outside the worktree the Session created', ready({ worktree: worktree({ created: created() }) }, { branch: null })],
|
||||
])('renders nothing %s', (_label, state) => {
|
||||
expect(chip(state).view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BranchChip label', () => {
|
||||
it('names the current branch and shows the checkout on hover', () => {
|
||||
const b = chip(ready())
|
||||
const label = b.label()
|
||||
expect(label.textContent).toBe('main')
|
||||
expect(label.hasAttribute('data-missing')).toBe(false)
|
||||
expect(detail(label)).toBe('Current checkout at /work/repo')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['its base branch', created(), 'Worktree at /work/repo-dsh, created from main'],
|
||||
['the commit it started from', created({ from: null, baseCommit: '0123456789abcdef' }), 'Worktree at /work/repo-dsh, created from 0123456'],
|
||||
])('names the branch of the worktree the Session created, with %s on hover', (_label, made, expected) => {
|
||||
const label = chip(inWorktree(made)).label()
|
||||
expect(label.textContent).toBe('dsh/0a1b2c3d')
|
||||
expect(label.hasAttribute('data-missing')).toBe(false)
|
||||
expect(detail(label)).toBe(expected)
|
||||
})
|
||||
|
||||
it('names the current branch once the Session works outside the worktree it created', () => {
|
||||
const b = chip(ready({ worktree: worktree({ created: created() }) }))
|
||||
expect(b.label().textContent).toBe('main')
|
||||
expect(detail(b.label())).toBe('Current checkout at /work/repo')
|
||||
})
|
||||
|
||||
it('follows the panel state from the first inspection into the created worktree', () => {
|
||||
const b = chip(ready({ checkout: { state: 'pending' } }))
|
||||
expect(b.view.container.innerHTML).toBe('')
|
||||
b.publish(ready())
|
||||
expect(b.label().textContent).toBe('main')
|
||||
b.publish(inWorktree())
|
||||
expect(b.label().textContent).toBe('dsh/0a1b2c3d')
|
||||
})
|
||||
})
|
||||
|
||||
describe('BranchChip removed worktree', () => {
|
||||
it('names the branch of the removed worktree the Session works in, marked missing, with its path on hover', () => {
|
||||
const label = chip(removed()).label()
|
||||
expect(label.textContent).toBe('dsh/0a1b2c3d')
|
||||
expect(label.hasAttribute('data-missing')).toBe(true)
|
||||
expect(detail(label)).toBe('Worktree removed: /work/repo-dsh')
|
||||
})
|
||||
|
||||
it('returns to the worktree label once the worktree is restored', () => {
|
||||
const b = chip(removed(false))
|
||||
expect(b.label().hasAttribute('data-missing')).toBe(true)
|
||||
b.publish(inWorktree())
|
||||
expect(b.label().textContent).toBe('dsh/0a1b2c3d')
|
||||
expect(b.label().hasAttribute('data-missing')).toBe(false)
|
||||
expect(detail(b.label())).toBe('Worktree at /work/repo-dsh, created from main')
|
||||
})
|
||||
|
||||
it('names the current branch when the panel reports a removal without the worktree record', () => {
|
||||
const label = chip(ready({ worktree: worktree({ missing: { branchExists: true } }) })).label()
|
||||
expect(label.textContent).toBe('main')
|
||||
expect(label.hasAttribute('data-missing')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,320 @@
|
||||
// @vitest-environment jsdom
|
||||
/** One change request card: the collapsed status summary, the read failure notices, and the expanded details and actions. */
|
||||
import { cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import type { ForgeFailure } from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import { ChangeRequestCard, type ChangeRequestActions } from '../src/client/ChangeRequestCard.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import css from '../src/client/ForgeTab.module.css'
|
||||
import type { ForgePanelChangeRequest } from '../src/types.ts'
|
||||
import { check, entry, snapshot, unserved } from './client-fixtures.client.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(en)
|
||||
const NOW = Date.parse('2026-10-09T12:00:00Z')
|
||||
/** Minus sign (U+2212) the diff size puts before the deleted line count. */
|
||||
const MINUS = '\u2212'
|
||||
|
||||
/** Render one card inside its list, with every action recorded unless `actions` replaces it. */
|
||||
function card(value: ForgePanelChangeRequest, options: { expanded?: boolean; actions?: Partial<ChangeRequestActions> } = {}) {
|
||||
const openLink = vi.fn<(url: string) => void>()
|
||||
const copyLink = vi.fn<(url: string) => void>()
|
||||
const retry = vi.fn<() => void>()
|
||||
const addToChat = vi.fn<(url: string) => void>()
|
||||
const remove = vi.fn<(entry: ForgePanelChangeRequest) => void>()
|
||||
const onToggle = vi.fn<() => void>()
|
||||
const actions: ChangeRequestActions = { openLink, copyLink, retry, addToChat, remove, ...options.actions }
|
||||
const view = render(
|
||||
<ul>
|
||||
<ChangeRequestCard entry={value} expanded={options.expanded ?? false} onToggle={onToggle} now={NOW} actions={actions} t={t} />
|
||||
</ul>,
|
||||
)
|
||||
const item = view.container.querySelector('[data-forge-change]')
|
||||
const head = item?.querySelector(`.${css.changeHead}`)
|
||||
if (!(item instanceof HTMLElement) || !(head instanceof HTMLElement)) throw new Error('no change request card')
|
||||
return { view, item, head, openLink, copyLink, retry, addToChat, remove, onToggle }
|
||||
}
|
||||
|
||||
/** The value a details row shows next to its label. */
|
||||
function rowValue(item: HTMLElement, label: string): HTMLElement {
|
||||
const value = within(item).getByText(label).nextElementSibling
|
||||
if (!(value instanceof HTMLElement)) throw new Error(`no value next to ${label}`)
|
||||
return value
|
||||
}
|
||||
|
||||
/** Name and time of each listed check, in display order. */
|
||||
function checkRows(item: HTMLElement): string[] {
|
||||
return [...item.querySelectorAll(`.${css.checks} .${css.check}`)]
|
||||
.map(row => `${row.querySelector(`.${css.code}`)?.textContent ?? ''}|${row.querySelector(`.${css.value}`)?.textContent ?? ''}`)
|
||||
}
|
||||
|
||||
describe('collapsed ChangeRequestCard', () => {
|
||||
it('summarizes an open change request by title, number, head branch, diff size, and checks', () => {
|
||||
const b = card(entry(7, snapshot(7, { headBranch: 'feat/login' })))
|
||||
expect(b.head.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(within(b.head).getByText('Change 7')).toBeTruthy()
|
||||
expect(within(b.head).getByText('#7')).toBeTruthy()
|
||||
expect(within(b.head).getByText('·')).toBeTruthy()
|
||||
expect(within(b.head).getByText('feat/login')).toBeTruthy()
|
||||
expect(within(b.head).getByText('+12')).toBeTruthy()
|
||||
expect(within(b.head).getByText(`${MINUS}3`)).toBeTruthy()
|
||||
expect(within(b.head).getByText(en['change.ci.passed']).querySelector('[data-state="done"]')).not.toBeNull()
|
||||
expect(b.head.querySelector('[data-flag]')).toBeNull()
|
||||
expect(within(b.item).queryByRole('button', { name: en['change.view'] })).toBeNull()
|
||||
|
||||
fireEvent.click(b.head)
|
||||
expect(b.onToggle).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an open', { state: 'open', draft: false }, 'open'],
|
||||
['a draft', { state: 'open', draft: true }, 'draft'],
|
||||
['a merged', { state: 'merged' }, 'merged'],
|
||||
['a closed', { state: 'closed' }, 'closed'],
|
||||
] as const)('tones the glyph of %s change request', (_name, overrides, tone) => {
|
||||
const b = card(entry(7, snapshot(7, overrides)))
|
||||
expect(b.head.querySelector(`.${css.glyph}`)?.getAttribute('data-tone')).toBe(tone)
|
||||
})
|
||||
|
||||
it('gives the glyph of a change request without a snapshot the unknown tone', () => {
|
||||
expect(card(unserved('https://gitlab.example/group/app/-/merge_requests/3')).head.querySelector(`.${css.glyph}`)?.getAttribute('data-tone')).toBe('unknown')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no checks', { checksRollup: 'none' }, en['change.ci.none'], undefined],
|
||||
['running checks', { checksRollup: 'pending' }, en['change.ci.running'], 'ongoing'],
|
||||
['listed failed checks', {
|
||||
checksRollup: 'failure',
|
||||
checks: [check('lint', { state: 'failure' }), check('test'), check('types', { state: 'failure' })],
|
||||
}, '2 failed', 'error'],
|
||||
['failed checks the host did not list', { checksRollup: 'failure', checksTruncated: true }, en['change.ci.failedUnlisted'], 'error'],
|
||||
] as const)('states %s', (_label, overrides, text, dot) => {
|
||||
const ci = within(card(entry(1, snapshot(1, overrides))).head).getByText(text)
|
||||
expect(ci.querySelector('[data-state]')?.getAttribute('data-state')).toBe(dot)
|
||||
expect(ci.getAttribute('data-tone')).toBe(dot === 'error' ? 'error' : null)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['requested changes', { reviewDecision: 'changes-requested', mergeability: 'conflicting' }, 'changesRequested', en['change.flag.changesRequested']],
|
||||
['merge conflicts', { mergeability: 'conflicting', draft: true }, 'conflicting', en['change.flag.conflicting']],
|
||||
['draft state', { draft: true }, 'draft', en['change.flag.draft']],
|
||||
] as const)('flags %s after the checks', (_label, overrides, flag, text) => {
|
||||
const b = card(entry(1, snapshot(1, overrides)))
|
||||
expect(b.head.querySelector('[data-flag]')?.getAttribute('data-flag')).toBe(flag)
|
||||
expect(within(b.head).getByText(text)).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['merged three hours ago', { state: 'merged', closedAt: '2026-10-09T09:00:00Z' }, 'merged', 'Merged · 3 h ago'],
|
||||
['closed without a close time', { state: 'closed' }, 'closed', en['change.state.closed']],
|
||||
['closed at a time that does not parse', { state: 'closed', closedAt: 'last week' }, 'closed', en['change.state.closed']],
|
||||
] as const)('states a change request %s instead of its checks', (_label, overrides, tone, text) => {
|
||||
const b = card(entry(1, snapshot(1, { ...overrides, checksRollup: 'failure', reviewDecision: 'changes-requested' })))
|
||||
const finished = within(b.head).getByText(text)
|
||||
expect(finished.getAttribute('data-tone')).toBe(tone)
|
||||
expect(within(b.head).getByText('+12')).toBeTruthy()
|
||||
expect(within(b.head).queryByText(en['change.ci.failedUnlisted'])).toBeNull()
|
||||
expect(b.head.querySelector('[data-flag]')).toBeNull()
|
||||
})
|
||||
|
||||
it('names the URL and the number of a change request before its first poll', () => {
|
||||
const b = card(entry(1, null))
|
||||
expect(within(b.head).getByText('github.com/octo/demo/pull/1')).toBeTruthy()
|
||||
expect(within(b.head).getByText('#1')).toBeTruthy()
|
||||
expect(within(b.head).queryByText('·')).toBeNull()
|
||||
expect(within(b.head).getByText(en['change.loading']).querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
expect(b.item.querySelector(`.${css.notice}`)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['not-found', en['change.failure.notFound']],
|
||||
['unauthenticated', en['change.failure.unauthenticated']],
|
||||
['unavailable', en['change.failure.unavailable']],
|
||||
] as const)('explains a %s read failure and retries it', (code, text) => {
|
||||
const failure: ForgeFailure = { code, message: 'detail' }
|
||||
const b = card(entry(1, failure))
|
||||
expect(within(b.head).getByText('github.com/octo/demo/pull/1')).toBeTruthy()
|
||||
expect(within(b.head).queryByText(en['change.loading'])).toBeNull()
|
||||
const notice = b.item.querySelector(`.${css.notice}`)
|
||||
if (!(notice instanceof HTMLElement)) throw new Error('no failure notice')
|
||||
expect(within(notice).getByText(text)).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(notice).getByRole('button', { name: en.retry }))
|
||||
expect(b.retry).toHaveBeenCalledOnce()
|
||||
expect(b.openLink).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps the last snapshot of a change request whose latest read failed, without its summary', () => {
|
||||
const b = card(entry(4, { code: 'unavailable', message: 'down' }, { kept: snapshot(4, { checksRollup: 'failure' }) }))
|
||||
expect(within(b.head).getByText('Change 4')).toBeTruthy()
|
||||
expect(within(b.head).getByText('feat')).toBeTruthy()
|
||||
expect(within(b.head).queryByText('+12')).toBeNull()
|
||||
expect(within(b.item).getByText(en['change.failure.unavailable'])).toBeTruthy()
|
||||
})
|
||||
|
||||
it('opens a URL no provider serves instead of retrying it', () => {
|
||||
const b = card(unserved('http://example.test/review/latest'))
|
||||
expect(within(b.head).getByText('example.test/review/latest')).toBeTruthy()
|
||||
expect(b.head.querySelector(`.${css.metaRow}`)).toBeNull()
|
||||
expect(b.head.querySelector(`.${css.summary}`)).toBeNull()
|
||||
const notice = b.item.querySelector(`.${css.notice}`)
|
||||
if (!(notice instanceof HTMLElement)) throw new Error('no unserved notice')
|
||||
expect(within(notice).getByText(en['change.unserved'])).toBeTruthy()
|
||||
expect(within(notice).queryByRole('button', { name: en.retry })).toBeNull()
|
||||
|
||||
fireEvent.click(within(notice).getByRole('button', { name: en['change.view'] }))
|
||||
expect(b.openLink).toHaveBeenCalledWith('http://example.test/review/latest')
|
||||
})
|
||||
|
||||
it('reads the number of an unserved URL from its path', () => {
|
||||
const b = card(unserved('https://example.test/review/17'))
|
||||
expect(within(b.head).getByText('#17')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows the head branch and opens the snapshot URL of an unnumbered change request', () => {
|
||||
const b = card({ ...unserved('https://example.test/review/latest'), snapshot: snapshot(3, { url: 'https://example.test/review/latest#web' }) })
|
||||
expect(within(b.head).getByText('Change 3')).toBeTruthy()
|
||||
expect(within(b.head).getByText('feat')).toBeTruthy()
|
||||
expect(within(b.head).queryByText('·')).toBeNull()
|
||||
expect(within(b.head).queryByText(/^#/)).toBeNull()
|
||||
|
||||
fireEvent.click(within(b.item).getByRole('button', { name: en['change.view'] }))
|
||||
expect(b.openLink).toHaveBeenCalledWith('https://example.test/review/latest#web')
|
||||
})
|
||||
})
|
||||
|
||||
describe('expanded ChangeRequestCard', () => {
|
||||
it('shows the details of an open change request and runs its actions on the snapshot URL', () => {
|
||||
const value = entry(7, snapshot(7, { url: 'https://github.com/octo/demo/pull/7#web' }))
|
||||
const b = card(value, { expanded: true })
|
||||
expect(b.item.getAttribute('data-forge-change')).toBe('https://github.com/octo/demo/pull/7')
|
||||
expect(b.head.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(rowValue(b.item, en['change.row.state']).textContent).toBe(`${en['change.state.open']} · ${en['change.origin.created']}`)
|
||||
expect(rowValue(b.item, en['change.row.checks']).textContent).toBe(en['change.ci.none'])
|
||||
expect(rowValue(b.item, en['change.row.review']).textContent).toBe(en['change.review.none'])
|
||||
expect(rowValue(b.item, en['change.row.merge']).textContent).toBe(en['change.merge.mergeable'])
|
||||
for (const label of [en['change.row.review'], en['change.row.merge']]) expect(rowValue(b.item, label).getAttribute('data-tone')).toBeNull()
|
||||
expect(within(b.item).getByRole('separator')).toBeTruthy()
|
||||
|
||||
fireEvent.click(within(b.item).getByRole('button', { name: en['change.view'] }))
|
||||
fireEvent.click(within(b.item).getByRole('button', { name: en['change.copy'] }))
|
||||
fireEvent.click(within(b.item).getByRole('button', { name: en['change.addToChat'] }))
|
||||
fireEvent.click(within(b.item).getByRole('button', { name: en['change.remove'] }))
|
||||
expect(b.openLink).toHaveBeenCalledWith('https://github.com/octo/demo/pull/7#web')
|
||||
expect(b.copyLink).toHaveBeenCalledWith('https://github.com/octo/demo/pull/7#web')
|
||||
expect(b.addToChat).toHaveBeenCalledWith('https://github.com/octo/demo/pull/7#web')
|
||||
expect(b.remove).toHaveBeenCalledWith(value)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a draft found after a push', { draft: true }, 'discovered', 'Draft · Found after a push', en['change.merge.mergeable']],
|
||||
['a tracked merged change request', { state: 'merged', mergeability: 'conflicting' }, 'tracked', 'Merged · Tracked', en['change.state.merged']],
|
||||
['a closed change request', { state: 'closed' }, 'created', 'Closed · Created in this Session', en['change.state.closed']],
|
||||
['an open change request whose mergeability is unknown', { mergeability: 'unknown' }, 'tracked', 'Ready for review · Tracked',
|
||||
en['change.merge.unknown']],
|
||||
] as const)('states %s', (_label, overrides, origin, state, merge) => {
|
||||
const b = card(entry(1, snapshot(1, overrides), { origin }), { expanded: true })
|
||||
expect(rowValue(b.item, en['change.row.state']).textContent).toBe(state)
|
||||
expect(rowValue(b.item, en['change.row.merge']).textContent).toBe(merge)
|
||||
expect(rowValue(b.item, en['change.row.merge']).getAttribute('data-tone')).toBeNull()
|
||||
})
|
||||
|
||||
it('marks merge conflicts of an open change request', () => {
|
||||
const b = card(entry(1, snapshot(1, { mergeability: 'conflicting' })), { expanded: true })
|
||||
expect(rowValue(b.item, en['change.row.merge']).textContent).toBe(en['change.merge.conflicting'])
|
||||
expect(rowValue(b.item, en['change.row.merge']).getAttribute('data-tone')).toBe('error')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an approval', { reviewDecision: 'approved' }, en['change.review.approved'], null],
|
||||
['a required review', { reviewDecision: 'review-required' }, en['change.review.required'], null],
|
||||
['requested changes', { reviewDecision: 'changes-requested' }, en['change.review.changesRequested'], 'error'],
|
||||
['an approval with unresolved threads', { reviewDecision: 'approved', unresolvedThreads: 2 }, 'Approved · 2 unresolved', null],
|
||||
['requested changes with an unresolved thread', { reviewDecision: 'changes-requested', unresolvedThreads: 1 },
|
||||
'Changes requested · 1 unresolved', 'error'],
|
||||
] as const)('states the review of %s', (_label, overrides, text, tone) => {
|
||||
const value = rowValue(card(entry(1, snapshot(1, overrides)), { expanded: true }).item, en['change.row.review'])
|
||||
expect(value.textContent).toBe(text)
|
||||
expect(value.getAttribute('data-tone')).toBe(tone)
|
||||
})
|
||||
|
||||
it('lists the checks failed first, then running, then the rest, with their times and links', () => {
|
||||
const checks = [
|
||||
check('build'),
|
||||
check('docs', { state: 'neutral' }),
|
||||
check('deploy', { state: 'pending', startedAt: null, completedAt: null }),
|
||||
check('lint', { state: 'failure', url: 'https://ci.example.test/lint', completedAt: '2026-10-09T11:00:00Z' }),
|
||||
check('e2e', { state: 'skipped', startedAt: null, completedAt: null }),
|
||||
check('test', { state: 'pending', completedAt: null }),
|
||||
check('package', { completedAt: null }),
|
||||
check('perf', { state: 'cancelled', startedAt: '2026-10-09T10:00:00Z', completedAt: '2026-10-09T11:30:20Z' }),
|
||||
]
|
||||
const b = card(entry(1, snapshot(1, { checks, checksRollup: 'failure', checksTruncated: true })), { expanded: true })
|
||||
const toggle = within(b.item).getByText(en['change.row.checks']).closest('button')
|
||||
if (toggle === null) throw new Error('the checks row does not expand')
|
||||
const summary = rowValue(b.item, en['change.row.checks'])
|
||||
expect(summary.textContent).toBe('1 failed · 2 running · 2 passed · 3 not run')
|
||||
expect(summary.getAttribute('data-tone')).toBe('error')
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(checkRows(b.item)).toEqual([])
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(checkRows(b.item)).toEqual([
|
||||
'lint|1h 0m', 'deploy|Queued', 'test|Running', 'build|1m 5s', 'package|', 'docs|1m 5s', 'e2e|', 'perf|1h 30m',
|
||||
])
|
||||
const rows = [...b.item.querySelectorAll(`.${css.checks} .${css.check}`)]
|
||||
expect(rows[0]?.querySelector(`.${css.fail}`)).not.toBeNull()
|
||||
expect(rows.slice(1, 3).map(row => row.querySelector('[data-state]')?.getAttribute('data-state'))).toEqual(['ongoing', 'ongoing'])
|
||||
expect(rows[3]?.querySelector(`.${css.pass}`)).not.toBeNull()
|
||||
expect(rows.slice(5).map(row => row.querySelector('[data-state]')?.getAttribute('data-state'))).toEqual(['idle', 'idle', 'idle'])
|
||||
expect(rows.map(row => row.tagName)).toEqual(['BUTTON', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV', 'DIV'])
|
||||
expect(within(b.item).getByText('Only the first 8 are listed')).toBeTruthy()
|
||||
|
||||
fireEvent.click(rows[0]!)
|
||||
expect(b.openLink).toHaveBeenCalledWith('https://ci.example.test/lint')
|
||||
|
||||
fireEvent.click(toggle)
|
||||
expect(toggle.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(checkRows(b.item)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
['passed checks', [check('build'), check('test')], '2 passed', ['build|1m 5s', 'test|1m 5s']],
|
||||
['running and skipped checks', [
|
||||
check('docs', { state: 'skipped', startedAt: null, completedAt: null }),
|
||||
check('test', { state: 'pending', completedAt: null }),
|
||||
], '1 running · 1 not run', ['test|Running', 'docs|']],
|
||||
])('counts %s without an error tone or a truncation note', (_label, checks, text, rows) => {
|
||||
const b = card(entry(1, snapshot(1, { checks, checksRollup: 'pending' })), { expanded: true })
|
||||
const summary = rowValue(b.item, en['change.row.checks'])
|
||||
expect(summary.textContent).toBe(text)
|
||||
expect(summary.getAttribute('data-tone')).toBeNull()
|
||||
fireEvent.click(within(b.item).getByText(en['change.row.checks']))
|
||||
expect(checkRows(b.item)).toEqual(rows)
|
||||
expect(b.item.querySelector(`.${css.truncated}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows only the link actions and the Session actions before the first poll', () => {
|
||||
const b = card(entry(1, null), { expanded: true })
|
||||
expect(within(b.item).queryByText(en['change.row.state'])).toBeNull()
|
||||
expect(within(b.item).queryByText(en['change.row.checks'])).toBeNull()
|
||||
expect(within(b.item).getByRole('separator')).toBeTruthy()
|
||||
fireEvent.click(within(b.item).getByRole('button', { name: en['change.view'] }))
|
||||
expect(b.openLink).toHaveBeenCalledWith('https://github.com/octo/demo/pull/1')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['neither Session action', { addToChat: undefined, remove: undefined }, []],
|
||||
['only adding to the chat', { remove: undefined }, [en['change.addToChat']]],
|
||||
['only removal', { addToChat: undefined }, [en['change.remove']]],
|
||||
] as const)('omits the Session actions a surface lacks, offering %s', (_label, actions, offered) => {
|
||||
const b = card(entry(1, snapshot(1)), { expanded: true, actions })
|
||||
const sessionActions = [en['change.addToChat'], en['change.remove']]
|
||||
.filter(name => within(b.item).queryByRole('button', { name }) !== null)
|
||||
expect(sessionActions).toEqual(offered)
|
||||
expect(within(b.item).queryAllByRole('separator')).toHaveLength(offered.length === 0 ? 0 : 1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
/** Panel state builders shared by the browser-side tests. */
|
||||
|
||||
import type {
|
||||
ChangeRequestCheck, ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure,
|
||||
} from '@deepseek-ai/dsh-experimental-forge/types'
|
||||
import type { BindingOrigin } from '@deepseek-ai/dsh-experimental-forge-session/types'
|
||||
import type {
|
||||
ForgeCheckout, ForgePanelChangeRequest, ForgePanelState, ForgePanelWorktree, ForgeSessionWorktree, ForgeWorktreeCreation,
|
||||
ForgeWorktreeFailure,
|
||||
} from '../src/types.ts'
|
||||
|
||||
/** Pull request reference in `octo/demo` on github.com. */
|
||||
export function ref(number: number): ChangeRequestRef {
|
||||
return { repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number }
|
||||
}
|
||||
|
||||
/** Open, mergeable change request from `feat` into `main` that adds 12 lines, deletes 3, and lists no checks. */
|
||||
export function snapshot(number: number, overrides: Partial<ChangeRequestSnapshot> = {}): ChangeRequestSnapshot {
|
||||
return {
|
||||
ref: ref(number),
|
||||
url: `https://github.com/octo/demo/pull/${number}`,
|
||||
title: `Change ${number}`,
|
||||
state: 'open',
|
||||
draft: false,
|
||||
author: 'octo',
|
||||
headBranch: 'feat',
|
||||
headSha: 'a'.repeat(40),
|
||||
baseBranch: 'main',
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
mergeability: 'mergeable',
|
||||
reviewDecision: 'none',
|
||||
checks: [],
|
||||
checksRollup: 'success',
|
||||
checksTruncated: false,
|
||||
unresolvedThreads: 0,
|
||||
updatedAt: '2026-10-09T00:00:00Z',
|
||||
closedAt: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Check of the head commit that succeeded after running from 10:00:00 to 10:01:05. */
|
||||
export function check(name: string, overrides: Partial<ChangeRequestCheck> = {}): ChangeRequestCheck {
|
||||
return {
|
||||
name,
|
||||
state: 'success',
|
||||
url: null,
|
||||
workflow: null,
|
||||
startedAt: '2026-10-09T10:00:00Z',
|
||||
completedAt: '2026-10-09T10:01:05Z',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Optional parts of a binding built by {@link entry}. */
|
||||
export interface EntryOptions {
|
||||
/** How the change request became bound; defaults to `created`. */
|
||||
readonly origin?: BindingOrigin
|
||||
/** Snapshot an earlier poll returned, kept by a binding whose latest poll failed. */
|
||||
readonly kept?: ChangeRequestSnapshot
|
||||
}
|
||||
|
||||
/** Binding of pull request `number` whose latest poll returned `status`, or that has not been polled yet. */
|
||||
export function entry(
|
||||
number: number,
|
||||
status: ChangeRequestSnapshot | ForgeFailure | null,
|
||||
options: EntryOptions = {},
|
||||
): ForgePanelChangeRequest {
|
||||
const key = `github:github.com/octo/demo#${number}` as ChangeRequestKey
|
||||
const binding = { url: `https://github.com/octo/demo/pull/${number}`, origin: options.origin ?? 'created', ref: ref(number) }
|
||||
if (status === null) return { ...binding, update: null, snapshot: null }
|
||||
if ('code' in status) return { ...binding, update: { key, fetchedAt: 1, failure: status }, snapshot: options.kept ?? null }
|
||||
return { ...binding, update: { key, fetchedAt: 1, snapshot: status }, snapshot: status }
|
||||
}
|
||||
|
||||
/** Binding of a URL that no provider serves. */
|
||||
export function unserved(url: string): ForgePanelChangeRequest {
|
||||
return { url, origin: 'created', ref: null, update: null, snapshot: null }
|
||||
}
|
||||
|
||||
/** Checkout of `/work/repo` on `main`. */
|
||||
export function checkout(overrides: Partial<ForgeCheckout> = {}): ForgeCheckout {
|
||||
return { root: '/work/repo', branch: 'main', ...overrides }
|
||||
}
|
||||
|
||||
/** Worktree the panel created at `/work/repo-dsh` from the checkout `/work/repo`, on a new branch started from `main`. */
|
||||
export function created(overrides: Partial<ForgeSessionWorktree> = {}): ForgeSessionWorktree {
|
||||
return { path: '/work/repo-dsh', branch: 'dsh/0a1b2c3d', from: 'main', baseCommit: 'c'.repeat(40), repositoryRoot: '/work/repo', ...overrides }
|
||||
}
|
||||
|
||||
/** Running creation of the branch `dsh/0a1b2c3d` from `main`, in its preparation step, that a message waits for. */
|
||||
export function creation(overrides: Partial<ForgeWorktreeCreation> = {}): ForgeWorktreeCreation {
|
||||
return { phase: 'prepare', branch: 'dsh/0a1b2c3d', from: 'main', held: true, ...overrides }
|
||||
}
|
||||
|
||||
/** Failed attempt to create the branch `dsh/0a1b2c3d` from `main`, in its preparation step. */
|
||||
export function failure(overrides: Partial<ForgeWorktreeFailure> = {}): ForgeWorktreeFailure {
|
||||
return { branch: 'dsh/0a1b2c3d', from: 'main', phase: 'prepare', message: 'fatal: invalid reference: main', ...overrides }
|
||||
}
|
||||
|
||||
/** Worktree facts of a Session that can no longer request a worktree, has no blocker, and has no creation, failure, or worktree. */
|
||||
export function worktree(overrides: Partial<ForgePanelWorktree> = {}): ForgePanelWorktree {
|
||||
return { editable: false, blocker: null, request: null, created: null, creation: null, failure: null, missing: null, ...overrides }
|
||||
}
|
||||
|
||||
/** Ready panel state of `/work/repo` recognized as `octo/demo`, signed in, without change requests or a worktree. */
|
||||
export function ready(overrides: Partial<ForgePanelState> = {}, checkoutOverrides: Partial<ForgeCheckout> = {}): ForgePanelState {
|
||||
return {
|
||||
directory: '/work/repo',
|
||||
checkout: { state: 'ready', checkout: checkout(checkoutOverrides) },
|
||||
repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' },
|
||||
readiness: { state: 'ready', message: null },
|
||||
changeRequests: [],
|
||||
worktree: worktree(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Ready panel state of a Session that waits to start in a worktree requested from `from`, with further worktree facts. */
|
||||
export function requested(from: string | null = 'main', overrides: Partial<ForgePanelWorktree> = {}): ForgePanelState {
|
||||
return ready({ worktree: worktree({ editable: true, request: { from }, ...overrides }) })
|
||||
}
|
||||
|
||||
/** Panel state of a Session that works in the worktree it created at `/work/repo-dsh` after that directory was removed. */
|
||||
export function removed(branchExists = true): ForgePanelState {
|
||||
return ready({
|
||||
directory: '/work/repo-dsh',
|
||||
checkout: { state: 'failed', message: 'fatal: cannot change to \'/work/repo-dsh\': No such file or directory' },
|
||||
worktree: worktree({ created: created(), missing: { branchExists } }),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Session header entry, forge tab title, and action toast rendering. */
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import toastCss from '@deepseek-ai/dsh-client-ui-primitives/src/Toast.module.css'
|
||||
import { ActionToast } from '../src/client/ActionToast.tsx'
|
||||
import { ForgeTabTitle } from '../src/client/ForgeTabTitle.tsx'
|
||||
import { HeaderButton } from '../src/client/HeaderButton.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import { createNoticeSource, type PanelSnapshot } from '../src/client/source.ts'
|
||||
import type { ForgePanelState } from '../src/types.ts'
|
||||
import { entry, ready, snapshot } from './client-fixtures.client.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const t = makeTranslate(en)
|
||||
|
||||
describe('HeaderButton', () => {
|
||||
function header(state: ForgePanelState | null, visible = false) {
|
||||
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: false })
|
||||
const visibility = createSnapshotStore<boolean>(visible)
|
||||
const toggle = vi.fn<(visible: boolean) => void>()
|
||||
const view = render(
|
||||
<HeaderButton usePanel={bindSnapshotSelector(panel)} useVisible={bindSnapshotSelector(visibility)} toggle={toggle} t={t} />,
|
||||
)
|
||||
return { panel, visibility, toggle, view, button: () => view.container.querySelector('[data-forge-header]') }
|
||||
}
|
||||
|
||||
it.each([
|
||||
['before the first state', null],
|
||||
['outside a Git checkout', ready({ checkout: { state: 'none' } })],
|
||||
['while the first inspection of an unknown repository runs', ready({ checkout: { state: 'pending' }, repository: null })],
|
||||
])('stays hidden %s', (_label, state) => {
|
||||
expect(header(state).view.container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a ready checkout', ready()],
|
||||
['a failed inspection', ready({ checkout: { state: 'failed', message: 'boom' } })],
|
||||
['a repeated inspection of a known repository', ready({ checkout: { state: 'pending' } })],
|
||||
['bound change requests outside a Git checkout', ready({ checkout: { state: 'none' }, changeRequests: [entry(1, snapshot(1))] })],
|
||||
])('opens the hidden tab for %s', (_label, state) => {
|
||||
const b = header(state)
|
||||
const button = b.view.getByRole('button', { name: en['header.label'] })
|
||||
expect(button.getAttribute('aria-pressed')).toBe('false')
|
||||
expect(button.querySelector('[data-state]')).toBeNull()
|
||||
fireEvent.click(button)
|
||||
expect(b.toggle).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('marks change requests that need attention', () => {
|
||||
const b = header(ready({ changeRequests: [entry(1, snapshot(1)), entry(2, snapshot(2, { checksRollup: 'failure' }))] }))
|
||||
const button = b.view.getByRole('button', { name: en['header.attention'] })
|
||||
expect(button.querySelector('[data-state="error"]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('follows the tab visibility and hides a visible tab', () => {
|
||||
const b = header(ready(), true)
|
||||
const button = b.view.getByRole('button', { name: en['header.label'] })
|
||||
expect(button.getAttribute('aria-pressed')).toBe('true')
|
||||
fireEvent.click(button)
|
||||
expect(b.toggle).toHaveBeenLastCalledWith(true)
|
||||
|
||||
act(() => { b.visibility.set(false) })
|
||||
expect(button.getAttribute('aria-pressed')).toBe('false')
|
||||
fireEvent.click(button)
|
||||
expect(b.toggle).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
|
||||
it('appears, changes its label, and disappears as the panel state changes', () => {
|
||||
const b = header(null)
|
||||
act(() => { b.panel.set({ state: ready(), disconnected: false }) })
|
||||
expect(b.button()?.getAttribute('aria-label')).toBe(en['header.label'])
|
||||
act(() => { b.panel.set({ state: ready({ changeRequests: [entry(1, snapshot(1, { mergeability: 'conflicting' }))] }), disconnected: false }) })
|
||||
expect(b.button()?.getAttribute('aria-label')).toBe(en['header.attention'])
|
||||
act(() => { b.panel.set({ state: ready({ checkout: { state: 'none' } }), disconnected: true }) })
|
||||
expect(b.button()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ForgeTabTitle', () => {
|
||||
it('shows the branch glyph and the tab name', () => {
|
||||
const view = render(<ForgeTabTitle t={t} />)
|
||||
expect(view.getByText(en['tab.title'])).toBeTruthy()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ActionToast', () => {
|
||||
function toast() {
|
||||
vi.useFakeTimers()
|
||||
const notices = createNoticeSource()
|
||||
const dismiss = vi.fn(notices.dismiss)
|
||||
const view = render(<ActionToast useNotice={bindSnapshotSelector(notices.hooks.notice)} dismiss={dismiss} t={t} />)
|
||||
return { notices, dismiss, view }
|
||||
}
|
||||
|
||||
it('renders nothing without a notice', () => {
|
||||
const b = toast()
|
||||
expect(b.view.container.innerHTML).toBe('')
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('warns about a failure until it fades', () => {
|
||||
const b = toast()
|
||||
act(() => { b.notices.report('toast.refreshFailed') })
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toBe(en['toast.refreshFailed'])
|
||||
expect(alert.querySelector(`.${toastCss.icon}`)).not.toBeNull()
|
||||
expect(alert.querySelector(`.${toastCss.success}`)).toBeNull()
|
||||
expect(within(alert).queryByRole('button')).toBeNull()
|
||||
|
||||
act(() => { vi.advanceTimersByTime(3999) })
|
||||
expect(b.dismiss).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(b.dismiss).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('confirms a success with its values', () => {
|
||||
const b = toast()
|
||||
act(() => { b.notices.report('toast.removed', { tone: 'success', params: { name: '#7' } }) })
|
||||
const alert = screen.getByRole('alert')
|
||||
expect(alert.textContent).toBe('Removed #7 from this Session')
|
||||
expect(alert.querySelector(`.${toastCss.success}`)).not.toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(4000) })
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('holds an undoable notice longer and dismisses it before undoing', () => {
|
||||
const b = toast()
|
||||
const order: string[] = []
|
||||
b.dismiss.mockImplementation(() => {
|
||||
order.push('dismiss')
|
||||
b.notices.dismiss()
|
||||
})
|
||||
act(() => { b.notices.report('toast.removed', { tone: 'success', params: { name: '#7' }, undo: () => { order.push('undo') } }) })
|
||||
act(() => { vi.advanceTimersByTime(6999) })
|
||||
expect(order).toEqual([])
|
||||
|
||||
fireEvent.click(within(screen.getByRole('alert')).getByRole('button', { name: en['toast.undo'] }))
|
||||
expect(order).toEqual(['dismiss', 'undo'])
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
|
||||
it('fades an undoable notice nobody acts on without undoing', () => {
|
||||
const b = toast()
|
||||
const undo = vi.fn()
|
||||
act(() => { b.notices.report('toast.removed', { tone: 'success', params: { name: '#7' }, undo }) })
|
||||
act(() => { vi.advanceTimersByTime(7000) })
|
||||
expect(b.dismiss).toHaveBeenCalledOnce()
|
||||
expect(undo).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restarts the hold for a repeated notice', () => {
|
||||
const b = toast()
|
||||
act(() => { b.notices.report('toast.copyFailed') })
|
||||
act(() => { vi.advanceTimersByTime(3000) })
|
||||
act(() => { b.notices.report('toast.copyFailed') })
|
||||
act(() => { vi.advanceTimersByTime(3000) })
|
||||
expect(screen.getByRole('alert').textContent).toBe(en['toast.copyFailed'])
|
||||
act(() => { vi.advanceTimersByTime(1000) })
|
||||
expect(b.dismiss).toHaveBeenCalledOnce()
|
||||
expect(screen.queryByRole('alert')).toBeNull()
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Forge tab body: connection states, the checkout row, the sign-in banner, the change request list, and tab visibility. */
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { InputActions, TokenSpan } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { PaneId, SidebarRightTabInfo, TabId } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { ForgeTab, type ForgeTabProps } from '../src/client/ForgeTab.tsx'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { PanelSnapshot } from '../src/client/source.ts'
|
||||
import css from '../src/client/ForgeTab.module.css'
|
||||
import type { ForgePanelChangeRequest, ForgePanelState } from '../src/types.ts'
|
||||
import { created, entry, ready, snapshot, worktree } from './client-fixtures.client.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(en)
|
||||
const unused = (): never => { throw new Error('the forge tab does not call this input action') }
|
||||
|
||||
/** Tab information of a forge tab whose visibility the test sets. */
|
||||
function tabInfo(visible: boolean): SidebarRightTabInfo {
|
||||
return {
|
||||
sidebar: { expanded: true, fullscreen: false },
|
||||
panel: { id: 'pane-1' as PaneId },
|
||||
tab: {
|
||||
id: 'tab-forge' as TabId,
|
||||
kind: 'forge-panel',
|
||||
contentId: 'sidebar://forge-panel',
|
||||
title: en['tab.title'],
|
||||
visible,
|
||||
navigation: { address: 'sidebar://forge-panel', params: undefined, revision: 0 },
|
||||
signal: new AbortController().signal,
|
||||
actions: { bindCommands: () => () => {}, openResource: unused, openTab: unused, close: unused },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function tab(state: ForgePanelState | null, options: { disconnected?: boolean; visible?: boolean; inserted?: boolean } = {}) {
|
||||
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: options.disconnected ?? false })
|
||||
const visibility = createSnapshotStore<boolean>(options.visible ?? true)
|
||||
const useVisibility = bindSnapshotSelector(visibility)
|
||||
const span: TokenSpan = { start: 4, end: 4, draftRev: 2 }
|
||||
const captureInsertion = vi.fn(() => span)
|
||||
const insertText = vi.fn((_text: string, _span: TokenSpan) => options.inserted ?? true)
|
||||
const inputActions: InputActions = {
|
||||
captureInsertion, insertText, setDraft: unused, persistDraft: unused,
|
||||
addAttachments: unused, removeAttachment: unused, pruneAttachments: unused, submit: unused,
|
||||
}
|
||||
const reconnect = vi.fn<() => void>()
|
||||
const refresh = vi.fn<() => void>()
|
||||
const remove = vi.fn<(entry: ForgePanelChangeRequest) => void>()
|
||||
const openLink = vi.fn<(url: string) => void>()
|
||||
const copyText = vi.fn<(text: string) => void>()
|
||||
const report = vi.fn<ForgeTabProps['report']>()
|
||||
const reportVisible = vi.fn<(visible: boolean) => void>()
|
||||
const props: ForgeTabProps = {
|
||||
useTabInfo: () => tabInfo(useVisibility(current => current)),
|
||||
inputActions,
|
||||
usePanel: bindSnapshotSelector(panel),
|
||||
reconnect, refresh, remove, openLink, copyText, report, reportVisible,
|
||||
t,
|
||||
}
|
||||
const view = render(<ForgeTab {...props} />)
|
||||
return {
|
||||
view, panel, visibility, span, captureInsertion, insertText, reconnect, refresh, remove, openLink, copyText, report, reportVisible,
|
||||
}
|
||||
}
|
||||
|
||||
/** The checkout row, or null when the tab shows none. */
|
||||
function checkoutRow(view: ReturnType<typeof render>): HTMLElement | null {
|
||||
const row = view.container.querySelector(`.${css.checkout}`)
|
||||
return row instanceof HTMLElement ? row : null
|
||||
}
|
||||
|
||||
/** The heads of the rendered change request cards, in list order. */
|
||||
function heads(view: ReturnType<typeof render>): HTMLElement[] {
|
||||
return [...view.container.querySelectorAll(`.${css.changeHead}`)].filter(head => head instanceof HTMLElement)
|
||||
}
|
||||
|
||||
describe('ForgeTab connection', () => {
|
||||
it('centers a spinner until the first state arrives', () => {
|
||||
const b = tab(null)
|
||||
expect(b.view.container.querySelector('[data-state="ongoing"]')).not.toBeNull()
|
||||
expect(b.view.container.querySelector('[data-forge-panel]')).toBeNull()
|
||||
expect(b.view.queryByRole('status')).toBeNull()
|
||||
|
||||
act(() => { b.panel.set({ state: ready(), disconnected: false }) })
|
||||
expect(b.view.container.querySelector('[data-forge-panel]')).not.toBeNull()
|
||||
})
|
||||
|
||||
it('replaces the spinner with a reconnect notice while disconnected', () => {
|
||||
const b = tab(null, { disconnected: true })
|
||||
expect(b.view.container.querySelector('[data-state="ongoing"]')).toBeNull()
|
||||
const status = b.view.getByRole('status')
|
||||
expect(within(status).getByText(en['stream.failed'])).toBeTruthy()
|
||||
fireEvent.click(within(status).getByRole('button', { name: en.retry }))
|
||||
expect(b.reconnect).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps the last panel state and adds the reconnect notice while disconnected', () => {
|
||||
const b = tab(ready(), { disconnected: true })
|
||||
const root = b.view.container.querySelector('[data-forge-panel]')
|
||||
if (!(root instanceof HTMLElement)) throw new Error('no panel')
|
||||
expect(root.firstElementChild?.getAttribute('role')).toBe('status')
|
||||
fireEvent.click(within(root).getByRole('button', { name: en.retry }))
|
||||
expect(b.reconnect).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => { b.panel.set({ state: ready(), disconnected: false }) })
|
||||
expect(b.view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ForgeTab checkout', () => {
|
||||
it('shows no checkout row while the first inspection runs', () => {
|
||||
const b = tab(ready({ checkout: { state: 'pending' } }))
|
||||
expect(checkoutRow(b.view)).toBeNull()
|
||||
expect(b.view.getByText('Pull requests of this Session · 0')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('explains a directory outside a Git checkout', () => {
|
||||
const b = tab(ready({ checkout: { state: 'none' } }))
|
||||
expect(checkoutRow(b.view)?.textContent).toBe(en['checkout.none'])
|
||||
})
|
||||
|
||||
it('shows a failed inspection and its message on hover', () => {
|
||||
const b = tab(ready({ checkout: { state: 'failed', message: 'git status failed (128): fatal' } }))
|
||||
const row = checkoutRow(b.view)
|
||||
if (row === null) throw new Error('no checkout row')
|
||||
expect(row.textContent).toBe(en['checkout.failed'])
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(b.view.getByRole('tooltip').textContent).toBe('git status failed (128): fatal')
|
||||
})
|
||||
|
||||
it('names the branch of the current checkout and shows its root on hover', () => {
|
||||
const b = tab(ready())
|
||||
const row = b.view.container.querySelector('[data-forge-checkout]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('no checkout row')
|
||||
expect([...row.querySelectorAll('span')].map(span => span.textContent)).toEqual([en['checkout.branch'], 'main', en['checkout.current']])
|
||||
fireEvent.mouseEnter(row)
|
||||
expect(b.view.getByRole('tooltip').textContent).toBe('/work/repo')
|
||||
})
|
||||
|
||||
it('names a detached HEAD', () => {
|
||||
const b = tab(ready({}, { branch: null }))
|
||||
expect(within(checkoutRow(b.view)!).getByText(en['checkout.detached'])).toBeTruthy()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['its base branch', created({ path: '/work/repo-dsh' }), 'From main'],
|
||||
['the commit it started from', created({ path: '/work/repo-dsh', from: null, baseCommit: '0123456789abcdef' }), 'From 0123456'],
|
||||
])('names the worktree this Session created with %s', (_label, made, base) => {
|
||||
const b = tab(ready({ directory: '/work/repo-dsh', worktree: worktree({ created: made }) }, { root: '/work/repo-dsh', branch: 'other' }))
|
||||
const row = b.view.container.querySelector('[data-forge-checkout]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('no checkout row')
|
||||
expect([...row.querySelectorAll('span')].map(span => span.textContent)).toEqual([en['checkout.worktree'], 'dsh/0a1b2c3d', base])
|
||||
})
|
||||
|
||||
it('names the current checkout once the Session works outside the worktree it created', () => {
|
||||
const b = tab(ready({ worktree: worktree({ created: created({ path: '/work/repo-dsh' }) }) }))
|
||||
const row = b.view.container.querySelector('[data-forge-checkout]')
|
||||
if (!(row instanceof HTMLElement)) throw new Error('no checkout row')
|
||||
expect([...row.querySelectorAll('span')].map(span => span.textContent)).toEqual([en['checkout.branch'], 'main', en['checkout.current']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ForgeTab sign-in banner', () => {
|
||||
it('offers the GitHub CLI sign-in command for an unauthenticated GitHub host', () => {
|
||||
const b = tab(ready({ readiness: { state: 'unauthenticated', message: 'not logged in' } }))
|
||||
const banner = b.view.getByRole('status')
|
||||
expect(within(banner).getByText('Sign in to github.com to show pull request status')).toBeTruthy()
|
||||
expect(within(banner).getByText('gh auth login --hostname github.com').tagName).toBe('CODE')
|
||||
expect(within(banner).queryByText('not logged in')).toBeNull()
|
||||
fireEvent.click(within(banner).getByRole('button', { name: en['signin.copy'] }))
|
||||
expect(b.copyText).toHaveBeenCalledWith('gh auth login --hostname github.com')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['with its message', 'token expired', ['Sign in to gitlab.example.test to show pull request status', 'token expired']],
|
||||
['without a message', null, ['Sign in to gitlab.example.test to show pull request status']],
|
||||
])('asks to sign in to another code host %s', (_label, message, texts) => {
|
||||
const b = tab(ready({
|
||||
repository: { forge: 'gitlab', host: 'gitlab.example.test', owner: 'octo', name: 'demo' },
|
||||
readiness: { state: 'unauthenticated', message },
|
||||
}))
|
||||
const banner = b.view.getByRole('status')
|
||||
expect([...banner.children].map(child => child.textContent)).toEqual(texts)
|
||||
expect(within(banner).queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['with its message', 'spawn gh ENOENT', 'Pull requests on github.com could not be read: spawn gh ENOENT'],
|
||||
['without a message', null, 'Pull requests on github.com could not be read: '],
|
||||
])('explains an unreachable host %s', (_label, message, text) => {
|
||||
const b = tab(ready({ readiness: { state: 'unavailable', message } }))
|
||||
expect(b.view.getByRole('status').textContent).toBe(text)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['sign-in is unknown', ready({ readiness: null })],
|
||||
['the host is signed in', ready()],
|
||||
['no provider serves the repository', ready({ repository: null, readiness: { state: 'unauthenticated', message: 'not logged in' } })],
|
||||
])('shows no banner while %s', (_label, state) => {
|
||||
const b = tab(state)
|
||||
expect(b.view.queryByRole('status')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ForgeTab change requests', () => {
|
||||
it('shows the empty state and refreshes on request', () => {
|
||||
const b = tab(ready())
|
||||
expect(b.view.getByText('Pull requests of this Session · 0')).toBeTruthy()
|
||||
expect(b.view.getByText(en['changes.empty'])).toBeTruthy()
|
||||
expect(b.view.getByText(en['changes.emptyHint'])).toBeTruthy()
|
||||
fireEvent.click(b.view.getByRole('button', { name: en.refresh }))
|
||||
expect(b.refresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('lists the bound change requests and expands one at a time', () => {
|
||||
const b = tab(ready({ changeRequests: [entry(1, snapshot(1)), entry(2, null)] }))
|
||||
expect(b.view.getByText('Pull requests of this Session · 2')).toBeTruthy()
|
||||
expect(b.view.queryByText(en['changes.empty'])).toBeNull()
|
||||
const expanded = () => heads(b.view).map(head => head.getAttribute('aria-expanded'))
|
||||
expect(expanded()).toEqual(['false', 'false'])
|
||||
|
||||
fireEvent.click(heads(b.view)[0]!)
|
||||
expect(expanded()).toEqual(['true', 'false'])
|
||||
fireEvent.click(heads(b.view)[1]!)
|
||||
expect(expanded()).toEqual(['false', 'true'])
|
||||
fireEvent.click(heads(b.view)[1]!)
|
||||
expect(expanded()).toEqual(['false', 'false'])
|
||||
})
|
||||
|
||||
it('wires the card actions to the tab actions', () => {
|
||||
const value = entry(7, snapshot(7))
|
||||
const b = tab(ready({ changeRequests: [value, entry(8, { code: 'unavailable', message: 'down' })] }))
|
||||
fireEvent.click(heads(b.view)[0]!)
|
||||
const card = b.view.container.querySelector('[data-forge-change="https://github.com/octo/demo/pull/7"]')
|
||||
if (!(card instanceof HTMLElement)) throw new Error('no card')
|
||||
|
||||
fireEvent.click(within(card).getByRole('button', { name: en['change.view'] }))
|
||||
fireEvent.click(within(card).getByRole('button', { name: en['change.copy'] }))
|
||||
fireEvent.click(within(card).getByRole('button', { name: en['change.remove'] }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: en.retry }))
|
||||
expect(b.openLink).toHaveBeenCalledWith('https://github.com/octo/demo/pull/7')
|
||||
expect(b.copyText).toHaveBeenCalledWith('https://github.com/octo/demo/pull/7')
|
||||
expect(b.remove).toHaveBeenCalledWith(value)
|
||||
expect(b.refresh).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('adds a change request URL to the composer at the captured insertion point', () => {
|
||||
const b = tab(ready({ changeRequests: [entry(7, snapshot(7))] }))
|
||||
fireEvent.click(heads(b.view)[0]!)
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['change.addToChat'] }))
|
||||
expect(b.captureInsertion).toHaveBeenCalledOnce()
|
||||
expect(b.insertText).toHaveBeenCalledWith('https://github.com/octo/demo/pull/7 ', b.span)
|
||||
expect(b.report).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a composer that refuses the insertion', () => {
|
||||
const b = tab(ready({ changeRequests: [entry(7, snapshot(7))] }), { inserted: false })
|
||||
fireEvent.click(heads(b.view)[0]!)
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['change.addToChat'] }))
|
||||
expect(b.report).toHaveBeenCalledWith('toast.chatFailed')
|
||||
})
|
||||
|
||||
it('dates a finished change request from the current time', () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'], now: Date.parse('2026-10-09T12:00:00Z') })
|
||||
try {
|
||||
const b = tab(ready({ changeRequests: [entry(1, snapshot(1, { state: 'merged', closedAt: '2026-10-09T11:00:00Z' }))] }))
|
||||
expect(b.view.getByText('Merged · 1 h ago')).toBeTruthy()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('ForgeTab visibility', () => {
|
||||
it('reports the tab visibility as it changes and hidden once unmounted', () => {
|
||||
const b = tab(ready())
|
||||
expect(b.reportVisible.mock.calls).toEqual([[true]])
|
||||
|
||||
act(() => { b.visibility.set(false) })
|
||||
act(() => { b.visibility.set(true) })
|
||||
expect(b.reportVisible.mock.calls).toEqual([[true], [false], [true]])
|
||||
|
||||
b.view.unmount()
|
||||
expect(b.reportVisible.mock.calls).toEqual([[true], [false], [true], [false]])
|
||||
})
|
||||
|
||||
it('reports a tab that mounts hidden, also before the first state', () => {
|
||||
const b = tab(null, { visible: false })
|
||||
expect(b.reportVisible.mock.calls).toEqual([[false]])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { formatAgo, formatDuration } from '../src/client/format.ts'
|
||||
import { en, zh } from '../src/client/locales.ts'
|
||||
|
||||
const t = makeTranslate(en)
|
||||
|
||||
describe('formatDuration', () => {
|
||||
it.each([
|
||||
[0, '0s'],
|
||||
[499, '0s'],
|
||||
[500, '1s'],
|
||||
[59_400, '59s'],
|
||||
[59_500, '1m 0s'],
|
||||
[65_000, '1m 5s'],
|
||||
[3_599_000, '59m 59s'],
|
||||
[3_600_000, '1h 0m'],
|
||||
[3_659_000, '1h 0m'],
|
||||
[3_660_000, '1h 1m'],
|
||||
[90_061_000, '25h 1m'],
|
||||
])('formats %i ms as %s', (ms, text) => {
|
||||
expect(formatDuration(ms, t)).toBe(text)
|
||||
})
|
||||
|
||||
it('uses the dictionary of the current language', () => {
|
||||
expect(formatDuration(65_000, makeTranslate(zh))).toBe('1 分 5 秒')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatAgo', () => {
|
||||
const now = Date.parse('2026-10-09T12:00:00Z')
|
||||
|
||||
it.each([
|
||||
['2026-10-09T11:59:30Z', 'just now'],
|
||||
['2026-10-09T11:55:00Z', '5 min ago'],
|
||||
['2026-10-09T09:00:00Z', '3 h ago'],
|
||||
['2026-10-07T12:00:00Z', '2 d ago'],
|
||||
['2026-06-01T12:00:00Z', '4 mo ago'],
|
||||
['2024-10-09T12:00:00Z', '2 y ago'],
|
||||
['2026-10-09T12:30:00Z', 'just now'],
|
||||
])('dates %s relative to noon', (iso, text) => {
|
||||
expect(formatAgo(iso, now, t)).toBe(text)
|
||||
})
|
||||
|
||||
it('dates nothing when the time does not parse', () => {
|
||||
expect(formatAgo('not a time', now, t)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,428 @@
|
||||
import { access, mkdir, readFile, stat, symlink, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox'
|
||||
import LocalSandbox from '@deepseek-ai/dsh-sandbox-local'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import { Session, SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import { hasBranch, inspectCheckout, listBranches } from '../src/git.ts'
|
||||
import type { GitConfig } from '../src/git.ts'
|
||||
import { commitAt, git, RecordingSandbox, repository, sandboxRecord, scratch, script } from './harness.ts'
|
||||
import type { SandboxRecord } from './harness.ts'
|
||||
|
||||
const CONFIG: GitConfig = { gitCommand: 'git', gitTimeoutMs: 10_000, graceMs: 500, maxOutputBytes: 1_048_576 }
|
||||
const GIT_PREFIX = ['--no-optional-locks', '-c', 'core.hooksPath=/dev/null', '-c', 'core.fsmonitor=false']
|
||||
const posix = process.platform === 'linux' || process.platform === 'darwin'
|
||||
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
||||
})
|
||||
|
||||
async function directory(prefix = 'dsh-forge-git-'): Promise<string> {
|
||||
const { path, remove } = await scratch(prefix)
|
||||
cleanups.push(remove)
|
||||
return path
|
||||
}
|
||||
|
||||
function session(cwd: string): Session {
|
||||
const id = SessionId('forge-git')
|
||||
return Session.create(id, [], { version: SESSION_FORMAT_VERSION, id, createdAt: 0, isSeeded: false, cwd })
|
||||
}
|
||||
|
||||
async function context(record: SandboxRecord, root: string, real = false): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
cleanups.push(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(LocalFileSystem, { cwd: root })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
if (real) await ctx.plugin(LocalSandbox)
|
||||
else await ctx.plugin(RecordingSandbox, record)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root })
|
||||
return ctx
|
||||
}
|
||||
|
||||
function live(): AbortSignal {
|
||||
return new AbortController().signal
|
||||
}
|
||||
|
||||
/** The subcommands of the recorded Git runs, after the fixed prefix, in sorted order. */
|
||||
function commands(record: SandboxRecord): string[] {
|
||||
return record.calls.map(call => call.argv.slice(1 + GIT_PREFIX.length).join(' ')).sort()
|
||||
}
|
||||
|
||||
/** A repository whose `main`, `newer`, and `older` tips were committed from latest to earliest, with `main` checked out. */
|
||||
async function branched(root: string): Promise<string> {
|
||||
const repo = await repository(root)
|
||||
await commitAt(repo, 'main.txt', '2026-03-01T00:00:00Z')
|
||||
await git(repo, 'checkout', '-b', 'older')
|
||||
await commitAt(repo, 'older.txt', '2026-01-01T00:00:00Z')
|
||||
await git(repo, 'checkout', '-b', 'newer', 'main')
|
||||
await commitAt(repo, 'newer.txt', '2026-02-01T00:00:00Z')
|
||||
await git(repo, 'checkout', 'main')
|
||||
return repo
|
||||
}
|
||||
|
||||
describe('inspectCheckout', () => {
|
||||
it('reads the root, shared Git directory, branch, upstream, and remotes of the checkout with five read-only commands', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
await git(root, 'init', '--bare', '-b', 'main', 'origin.git')
|
||||
await git(repo, 'remote', 'add', 'origin', join(root, 'origin.git'))
|
||||
await git(repo, 'remote', 'add', 'fake', 'https://fake.test/octo/demo.git')
|
||||
await git(repo, 'push', '-u', 'origin', 'main')
|
||||
const nested = join(repo, 'src')
|
||||
await mkdir(nested)
|
||||
const record = sandboxRecord()
|
||||
const ctx = await context(record, repo)
|
||||
|
||||
const inspection = await inspectCheckout(ctx, CONFIG, session(repo), nested, live())
|
||||
|
||||
expect(inspection?.checkout).toEqual({ root: repo, branch: 'main' })
|
||||
expect(inspection?.commonDir).toBe(join(repo, '.git'))
|
||||
expect(inspection?.upstream).toBe('origin/main')
|
||||
expect(inspection?.remotes).toHaveLength(2)
|
||||
expect(inspection?.remotes).toEqual(expect.arrayContaining([
|
||||
{ name: 'fake', urls: ['https://fake.test/octo/demo.git'] },
|
||||
{ name: 'origin', urls: [join(root, 'origin.git')] },
|
||||
]))
|
||||
expect(record.calls[0]?.argv.slice(1 + GIT_PREFIX.length)).toEqual(['rev-parse', '--show-toplevel'])
|
||||
expect(commands(record)).toEqual([
|
||||
'remote -v',
|
||||
'rev-parse --abbrev-ref --symbolic-full-name @{upstream}',
|
||||
'rev-parse --path-format=absolute --git-common-dir',
|
||||
'rev-parse --show-toplevel',
|
||||
'symbolic-ref --quiet --short HEAD',
|
||||
])
|
||||
for (const call of record.calls) {
|
||||
expect(call.policy).toMatchObject({ mode: 'read-only', workspaceRoot: repo })
|
||||
expect(call.argv.slice(1, 1 + GIT_PREFIX.length)).toEqual(GIT_PREFIX)
|
||||
}
|
||||
})
|
||||
|
||||
it('inspects a linked worktree as its own checkout that shares the Git directory of its repository', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
const linked = join(root, 'linked')
|
||||
await git(repo, 'worktree', 'add', '-b', 'feat', linked)
|
||||
const ctx = await context(sandboxRecord(), repo)
|
||||
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(repo), linked, live())).resolves.toEqual({
|
||||
checkout: { root: linked, branch: 'feat' }, commonDir: join(repo, '.git'), upstream: null, remotes: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined outside a checkout and inside a Git directory', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(root), root, live())).resolves.toBeUndefined()
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(root), join(repo, '.git'), live())).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports an unborn branch and a detached HEAD', async () => {
|
||||
const root = await directory()
|
||||
await git(root, 'init', '-b', 'main', 'empty')
|
||||
const repo = await repository(root)
|
||||
await git(repo, 'checkout', '--detach')
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(root), join(root, 'empty'), live())).resolves.toEqual({
|
||||
checkout: { root: join(root, 'empty'), branch: 'main' }, commonDir: join(root, 'empty', '.git'), upstream: null, remotes: [],
|
||||
})
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(root), repo, live())).resolves.toEqual({
|
||||
checkout: { root: repo, branch: null }, commonDir: join(repo, '.git'), upstream: null, remotes: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('stops when the caller aborts', async () => {
|
||||
const root = await directory()
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('closed'))
|
||||
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(root), root, controller.signal)).rejects.toThrow('closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe.skipIf(!posix)('inspectCheckout with a stand-in Git', () => {
|
||||
it('never runs a configured filter, because it reads no working tree file', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
const marker = join(root, 'filter-executed')
|
||||
await writeFile(join(repo, '.gitattributes'), '*.txt filter=marker\n')
|
||||
await git(repo, 'config', 'filter.marker.clean', `sh -c 'echo executed >> "${marker}"; cat'`)
|
||||
await writeFile(join(repo, 'tracked.txt'), 'changed\n')
|
||||
const ctx = await context(sandboxRecord(), repo)
|
||||
|
||||
const inspection = await inspectCheckout(ctx, CONFIG, session(repo), repo, live())
|
||||
|
||||
expect(inspection?.checkout).toEqual({ root: repo, branch: 'main' })
|
||||
await expect(access(marker)).rejects.toThrow()
|
||||
await git(repo, 'status', '--porcelain')
|
||||
expect(await readFile(marker, 'utf8')).toContain('executed')
|
||||
})
|
||||
|
||||
it('runs Git with a cleared repository selector and disabled prompts, locks, and lazy fetches', async () => {
|
||||
const root = await directory()
|
||||
const log = join(root, 'env.log')
|
||||
const fake = await script(join(root, 'git'), [
|
||||
'case "$*" in',
|
||||
` *--show-toplevel*) env | grep -E '^(GIT_|LC_ALL)' | sort > "${log}"; echo /work/repo ;;`,
|
||||
' *--git-common-dir*) echo /work/repo/.git ;;',
|
||||
' *symbolic-ref*) exit 1 ;;',
|
||||
' *--abbrev-ref*) exit 128 ;;',
|
||||
'esac',
|
||||
].join('\n'))
|
||||
vi.stubEnv('GIT_DIR', join(root, 'elsewhere.git'))
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
try {
|
||||
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).resolves.toEqual({
|
||||
checkout: { root: '/work/repo', branch: null }, commonDir: '/work/repo/.git', upstream: null, remotes: [],
|
||||
})
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
const environment = await readFile(log, 'utf8')
|
||||
expect(environment).toContain('GIT_OPTIONAL_LOCKS=0')
|
||||
expect(environment).toContain('GIT_NO_LAZY_FETCH=1')
|
||||
expect(environment).toContain('GIT_TERMINAL_PROMPT=0')
|
||||
expect(environment).toContain('LC_ALL=C')
|
||||
expect(environment).not.toContain('GIT_DIR=')
|
||||
})
|
||||
|
||||
it('resolves the top-level and shared Git directories Git prints into canonical process paths', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
const alias = join(root, 'alias')
|
||||
await symlink(repo, alias)
|
||||
const fake = await script(join(root, 'git'), [
|
||||
'case "$*" in',
|
||||
` *--show-toplevel*) echo "${alias}/" ;;`,
|
||||
` *--git-common-dir*) echo "${alias}/.git" ;;`,
|
||||
' *symbolic-ref*) echo main ;;',
|
||||
' *--abbrev-ref*) echo origin/main ;;',
|
||||
'esac',
|
||||
].join('\n'))
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).resolves.toEqual({
|
||||
checkout: { root: repo, branch: 'main' }, commonDir: join(repo, '.git'), upstream: 'origin/main', remotes: [],
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an unexpected exit', 'echo broken >&2; exit 2', {}, 'git rev-parse failed (2): broken'],
|
||||
['another rev-parse error', 'echo "fatal: detected dubious ownership" >&2; exit 128', {}, 'git rev-parse failed: fatal: detected dubious ownership'],
|
||||
['a signal', 'kill -9 $$', {}, 'git rev-parse failed (SIGKILL): no diagnostic'],
|
||||
['missing output', 'exit 0', {}, 'git rev-parse printed no directory'],
|
||||
['oversized output', 'echo /a/very/long/top/level/path', { maxOutputBytes: 4 }, 'git rev-parse output exceeds maxOutputBytes'],
|
||||
['a timeout', 'sleep 5', { gitTimeoutMs: 100 }, 'FORGE_PANEL_GIT_TIMEOUT after 100ms'],
|
||||
])('fails on %s', async (_label, body, overrides: Partial<GitConfig>, message) => {
|
||||
const root = await directory()
|
||||
const fake = await script(join(root, 'git'), body)
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, { ...CONFIG, ...overrides, gitCommand: fake }, session(root), root, live())).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('fails when the Git executable is missing', async () => {
|
||||
const root = await directory()
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: join(root, 'missing-git') }, session(root), root, live())).rejects.toThrow()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['common-dir', '*--git-common-dir*) echo "fatal: bad common dir" >&2; exit 128 ;;', 'git common-dir failed (128): fatal: bad common dir'],
|
||||
['symbolic-ref', '*symbolic-ref*) echo "fatal: bad HEAD" >&2; exit 128 ;;', 'git symbolic-ref failed (128): fatal: bad HEAD'],
|
||||
['upstream', '*--abbrev-ref*) echo "fatal: bad upstream" >&2; exit 1 ;;', 'git upstream failed (1): fatal: bad upstream'],
|
||||
['remote', '*remote*) echo "fatal: bad config" >&2; exit 3 ;;', 'git remote failed (3): fatal: bad config'],
|
||||
])('names a failing %s command after the top-level directory', async (_name, failure, message) => {
|
||||
const root = await directory()
|
||||
const fake = await script(join(root, 'git'), [
|
||||
'case "$*" in',
|
||||
` ${failure}`,
|
||||
' *--show-toplevel*) echo /work/repo ;;',
|
||||
' *--git-common-dir*) echo /work/repo/.git ;;',
|
||||
'esac',
|
||||
].join('\n'))
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).rejects.toThrow(message)
|
||||
})
|
||||
|
||||
it('rejects a shared Git directory that Git does not print', async () => {
|
||||
const root = await directory()
|
||||
const fake = await script(join(root, 'git'), ['case "$*" in', ' *--show-toplevel*) echo /work/repo ;;', 'esac'].join('\n'))
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(inspectCheckout(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, live())).rejects.toThrow('git rev-parse printed no directory')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listBranches', () => {
|
||||
it('lists the checked-out branch first, then local branches by latest commit, with two read-only commands', async () => {
|
||||
const root = await directory()
|
||||
const repo = await branched(root)
|
||||
const record = sandboxRecord()
|
||||
const ctx = await context(record, repo)
|
||||
|
||||
await expect(listBranches(ctx, CONFIG, session(repo), repo, 50, live())).resolves.toEqual({
|
||||
current: 'main', branches: ['main', 'newer', 'older'],
|
||||
})
|
||||
expect(commands(record)).toEqual([
|
||||
'for-each-ref --sort=-committerdate --count=50 --format=%(refname:lstrip=2) refs/heads/',
|
||||
'symbolic-ref --quiet --short HEAD',
|
||||
])
|
||||
for (const call of record.calls) {
|
||||
expect(call.policy).toMatchObject({ mode: 'read-only', workspaceRoot: repo })
|
||||
expect(call.argv.slice(1, 1 + GIT_PREFIX.length)).toEqual(GIT_PREFIX)
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the checked-out branch first when the limit cuts it from the listing', async () => {
|
||||
const root = await directory()
|
||||
const repo = await branched(root)
|
||||
await git(repo, 'checkout', 'older')
|
||||
const ctx = await context(sandboxRecord(), repo)
|
||||
|
||||
await expect(listBranches(ctx, CONFIG, session(repo), repo, 2, live())).resolves.toEqual({ current: 'older', branches: ['older', 'main'] })
|
||||
await expect(listBranches(ctx, CONFIG, session(repo), repo, 1, live())).resolves.toEqual({ current: 'older', branches: ['older'] })
|
||||
})
|
||||
|
||||
it('lists the branches of a detached HEAD without a current one, and an unborn branch alone', async () => {
|
||||
const root = await directory()
|
||||
const repo = await branched(root)
|
||||
await git(repo, 'checkout', '--detach', 'main')
|
||||
await git(root, 'init', '-b', 'main', 'empty')
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(listBranches(ctx, CONFIG, session(root), repo, 2, live())).resolves.toEqual({ current: null, branches: ['main', 'newer'] })
|
||||
await expect(listBranches(ctx, CONFIG, session(root), join(root, 'empty'), 50, live())).resolves.toEqual({ current: 'main', branches: ['main'] })
|
||||
})
|
||||
|
||||
it('fails outside a checkout', async () => {
|
||||
const root = await directory()
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(listBranches(ctx, CONFIG, session(root), root, 50, live()))
|
||||
.rejects.toThrow(/^git (symbolic-ref|for-each-ref) failed \(128\): fatal: not a git repository/)
|
||||
})
|
||||
|
||||
it.skipIf(!posix)('names a failing branch listing', async () => {
|
||||
const root = await directory()
|
||||
const fake = await script(join(root, 'git'), [
|
||||
'case "$*" in',
|
||||
' *for-each-ref*) echo "fatal: bad ref" >&2; exit 2 ;;',
|
||||
' *symbolic-ref*) echo main ;;',
|
||||
'esac',
|
||||
].join('\n'))
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(listBranches(ctx, { ...CONFIG, gitCommand: fake }, session(root), root, 50, live()))
|
||||
.rejects.toThrow('git for-each-ref failed (2): fatal: bad ref')
|
||||
})
|
||||
|
||||
it('stops when the caller aborts', async () => {
|
||||
const root = await directory()
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('closed'))
|
||||
|
||||
await expect(listBranches(ctx, CONFIG, session(root), root, 50, controller.signal)).rejects.toThrow('closed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasBranch', () => {
|
||||
it('reports whether a local branch exists with one read-only command per question', async () => {
|
||||
const root = await directory()
|
||||
const repo = await branched(root)
|
||||
const record = sandboxRecord()
|
||||
const ctx = await context(record, repo)
|
||||
|
||||
await expect(hasBranch(ctx, CONFIG, session(repo), repo, 'newer', live())).resolves.toBe(true)
|
||||
await expect(hasBranch(ctx, CONFIG, session(repo), repo, 'dsh/0a1b2c3d', live())).resolves.toBe(false)
|
||||
|
||||
expect(record.calls.map(call => call.argv.slice(1 + GIT_PREFIX.length).join(' '))).toEqual([
|
||||
'show-ref --verify --quiet refs/heads/newer',
|
||||
'show-ref --verify --quiet refs/heads/dsh/0a1b2c3d',
|
||||
])
|
||||
for (const call of record.calls) {
|
||||
expect(call.policy).toMatchObject({ mode: 'read-only', workspaceRoot: repo })
|
||||
expect(call.argv.slice(1, 1 + GIT_PREFIX.length)).toEqual(GIT_PREFIX)
|
||||
}
|
||||
})
|
||||
|
||||
it('reads the branches of the repository from a linked worktree', async () => {
|
||||
const root = await directory()
|
||||
const repo = await branched(root)
|
||||
const linked = join(root, 'linked')
|
||||
await git(repo, 'worktree', 'add', '-b', 'feat', linked)
|
||||
const ctx = await context(sandboxRecord(), repo)
|
||||
|
||||
await expect(hasBranch(ctx, CONFIG, session(repo), linked, 'older', live())).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('fails outside a repository', async () => {
|
||||
const root = await directory()
|
||||
const ctx = await context(sandboxRecord(), root)
|
||||
|
||||
await expect(hasBranch(ctx, CONFIG, session(root), root, 'main', live()))
|
||||
.rejects.toThrow(/^git show-ref failed \(128\): fatal: not a git repository/)
|
||||
})
|
||||
|
||||
it('stops when the caller aborts', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
const ctx = await context(sandboxRecord(), repo)
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('closed'))
|
||||
|
||||
await expect(hasBranch(ctx, CONFIG, session(repo), repo, 'main', controller.signal)).rejects.toThrow('closed')
|
||||
})
|
||||
})
|
||||
|
||||
const sandboxUsable = await (async () => {
|
||||
if (!posix) return false
|
||||
const probe = new Context()
|
||||
try {
|
||||
await probe.plugin(LocalSandbox)
|
||||
await probe.sandbox.confine([process.execPath, '--version'], { mode: 'read-only', workspaceRoot: process.cwd() })
|
||||
return true
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SandboxUnavailableError) return false
|
||||
throw error
|
||||
} finally {
|
||||
await probe.fiber.dispose()
|
||||
}
|
||||
})()
|
||||
|
||||
describe.skipIf(!sandboxUsable)('Git inspection under the local sandbox', () => {
|
||||
it('reads a checkout and lists its branches under read-only confinement without writing its index', async () => {
|
||||
const root = await directory()
|
||||
const repo = await repository(root)
|
||||
await writeFile(join(repo, 'tracked.txt'), 'changed\n')
|
||||
const index = join(repo, '.git', 'index')
|
||||
const before = await stat(index)
|
||||
const ctx = await context(sandboxRecord(), repo, true)
|
||||
const confine = vi.spyOn(ctx.sandbox, 'confine')
|
||||
|
||||
await expect(inspectCheckout(ctx, CONFIG, session(repo), repo, live())).resolves.toEqual({
|
||||
checkout: { root: repo, branch: 'main' }, commonDir: join(repo, '.git'), upstream: null, remotes: [],
|
||||
})
|
||||
await expect(listBranches(ctx, CONFIG, session(repo), repo, 50, live())).resolves.toEqual({ current: 'main', branches: ['main'] })
|
||||
|
||||
expect(confine).toHaveBeenCalledTimes(7)
|
||||
expect(confine.mock.calls.every(call => call[1].mode === 'read-only')).toBe(true)
|
||||
expect((await stat(index)).mtimeMs).toBe(before.mtimeMs)
|
||||
await expect(access(join(repo, '.git', 'index.lock'))).rejects.toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,541 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { statSync } from 'node:fs'
|
||||
import type { Stats } from 'node:fs'
|
||||
import { chmod, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { delimiter, join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { expect } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import AgentController from '@deepseek-ai/dsh-agent-controller'
|
||||
import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent'
|
||||
import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit'
|
||||
import ForgeService, { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
||||
import type {
|
||||
ChangeRequestKey, ChangeRequestRef, ChangeRequestSnapshot, ForgeFailure, ForgeProvider, ForgeReadiness, ForgeRepository,
|
||||
} from '@deepseek-ai/dsh-experimental-forge'
|
||||
import ForgeSessionService from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
import WorktreeService from '@deepseek-ai/dsh-experimental-worktree'
|
||||
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
|
||||
import { createAssistantMessage, createToolResultMessage, createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
|
||||
import SandboxProvider from '@deepseek-ai/dsh-sandbox'
|
||||
import type { ConfinedArgv, SandboxPolicy } from '@deepseek-ai/dsh-sandbox'
|
||||
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
|
||||
import SessionStore, { pluginRecordOf, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, UserMessage } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import WorkingDirectoryService from '@deepseek-ai/dsh-working-directory'
|
||||
import ForgePanelController from '../src/index.ts'
|
||||
import type { Config, ForgePanelState, ForgeWorktreeState } from '../src/index.ts'
|
||||
import { forgeWorktreeProjection, WORKTREE_RECORD } from '../src/worktree.ts'
|
||||
|
||||
const execute = promisify(execFile)
|
||||
|
||||
function executableFile(path: string): boolean {
|
||||
let stats: Stats
|
||||
try {
|
||||
stats = statSync(path)
|
||||
} catch {
|
||||
return false // A missing or unreadable candidate moves the search to the next PATH entry.
|
||||
}
|
||||
return stats.isFile() && (process.platform === 'win32' || (stats.mode & 0o111) !== 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The first Git executable on PATH. Spawning the bare name makes macOS try every PATH entry
|
||||
* in turn, which costs tens of milliseconds per fixture command under the long PATH of a test run.
|
||||
*/
|
||||
const GIT = (process.env.PATH ?? '').split(delimiter)
|
||||
.map(directory => join(directory, process.platform === 'win32' ? 'git.exe' : 'git'))
|
||||
.find(executableFile) ?? 'git'
|
||||
|
||||
async function runGit(cwd: string, args: readonly string[], env: NodeJS.ProcessEnv = {}): Promise<string> {
|
||||
const result = await execute(GIT, ['-c', 'core.hooksPath=/dev/null', ...args], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '', GIT_CONFIG_PARAMETERS: '', GIT_CONFIG_COUNT: '0',
|
||||
GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined,
|
||||
...env,
|
||||
},
|
||||
})
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
/** Run the real Git CLI for fixtures, isolated from the developer's configuration. */
|
||||
export async function git(cwd: string, ...args: string[]): Promise<string> {
|
||||
return await runGit(cwd, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit one new file on the checked-out branch with a fixed author and committer date.
|
||||
* @param repo - repository path.
|
||||
* @param file - new file name inside `repo`; its content is its name.
|
||||
* @param date - ISO 8601 author and committer date.
|
||||
* @returns the new commit's object name.
|
||||
*/
|
||||
export async function commitAt(repo: string, file: string, date: string): Promise<string> {
|
||||
await writeFile(join(repo, file), `${file}\n`)
|
||||
await git(repo, 'add', file)
|
||||
await runGit(repo, ['commit', '-m', file], { GIT_AUTHOR_DATE: date, GIT_COMMITTER_DATE: date })
|
||||
return await git(repo, 'rev-parse', 'HEAD')
|
||||
}
|
||||
|
||||
/**
|
||||
* List the local branches of a repository.
|
||||
* @param repo - repository path.
|
||||
* @returns short branch names in ref name order.
|
||||
*/
|
||||
export async function branches(repo: string): Promise<string[]> {
|
||||
return (await git(repo, 'for-each-ref', '--format=%(refname:short)', 'refs/heads/')).split('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Await an operation that must fail.
|
||||
* @param operation - operation expected to reject.
|
||||
* @returns the rejection reason.
|
||||
* @throws when the operation fulfills.
|
||||
*/
|
||||
export async function rejectionOf(operation: Promise<unknown>): Promise<unknown> {
|
||||
try {
|
||||
await operation
|
||||
} catch (error: unknown) {
|
||||
return error
|
||||
}
|
||||
throw new Error('the operation fulfilled')
|
||||
}
|
||||
|
||||
/** A private temporary directory removed by the returned disposer. */
|
||||
export async function scratch(prefix: string): Promise<{ path: string; remove: () => Promise<void> }> {
|
||||
const path = await realpath(await mkdtemp(join(tmpdir(), prefix)))
|
||||
return { path, remove: () => rm(path, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a repository with one commit on `main` inside `parent`.
|
||||
* @param parent - directory that receives `name`.
|
||||
* @param name - repository directory name.
|
||||
* @returns the repository path.
|
||||
*/
|
||||
export async function repository(parent: string, name = 'repo'): Promise<string> {
|
||||
const path = join(parent, name)
|
||||
await git(parent, 'init', '-b', 'main', name)
|
||||
await git(path, 'config', 'core.autocrlf', 'false')
|
||||
await git(path, 'config', 'user.name', 'Forge fixture')
|
||||
await git(path, 'config', 'user.email', 'forge@example.invalid')
|
||||
await git(path, 'config', 'commit.gpgSign', 'false')
|
||||
await writeFile(join(path, 'tracked.txt'), 'initial\n')
|
||||
await git(path, 'add', 'tracked.txt')
|
||||
await git(path, 'commit', '-m', 'initial')
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an executable POSIX script.
|
||||
* @param path - script file.
|
||||
* @param body - shell commands after the shebang.
|
||||
* @returns `path`.
|
||||
*/
|
||||
export async function script(path: string, body: string): Promise<string> {
|
||||
await writeFile(path, `#!/bin/sh\n${body}\n`)
|
||||
await chmod(path, 0o755)
|
||||
return path
|
||||
}
|
||||
|
||||
/**
|
||||
* Confinements seen by the recording sandbox; a set `failure` is thrown instead of confining, and a set `gate`
|
||||
* delays each recorded confinement until the promise it returns for the argv settles.
|
||||
*/
|
||||
export interface SandboxRecord {
|
||||
readonly calls: { argv: readonly string[]; policy: SandboxPolicy }[]
|
||||
failure: unknown
|
||||
gate: ((argv: readonly string[]) => Promise<void> | undefined) | undefined
|
||||
}
|
||||
|
||||
/** A fresh record without failure or gate. */
|
||||
export function sandboxRecord(): SandboxRecord {
|
||||
return { calls: [], failure: undefined, gate: undefined }
|
||||
}
|
||||
|
||||
/** Sandbox double that records each confinement in its plugin config and returns the argv unchanged. */
|
||||
export class RecordingSandbox extends SandboxProvider {
|
||||
constructor(ctx: Context, private readonly record: SandboxRecord) {
|
||||
super(ctx)
|
||||
}
|
||||
|
||||
async confine(argv: readonly string[], policy: SandboxPolicy): Promise<ConfinedArgv> {
|
||||
if (this.record.failure !== undefined) throw this.record.failure
|
||||
this.record.calls.push({ argv, policy })
|
||||
await this.record.gate?.(argv)
|
||||
return { argv: [...argv], enforcement: 'full', denialSignatures: [], runnerFailureRules: [] }
|
||||
}
|
||||
}
|
||||
|
||||
/** Confinements held by {@link hold}. */
|
||||
export interface Hold {
|
||||
/** Settles when the first matching command reaches the sandbox. */
|
||||
readonly reached: Promise<void>
|
||||
/** Let every held and later matching command run. */
|
||||
readonly release: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold each confinement whose argv contains `argument` until released; other confinements keep the record's previous gate.
|
||||
* @param record - sandbox record whose gate is wrapped.
|
||||
* @param argument - exact argv element that selects the held commands, such as a Git subcommand.
|
||||
* @returns the arrival signal and the release.
|
||||
*/
|
||||
export function hold(record: SandboxRecord, argument: string): Hold {
|
||||
const reached: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const released: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
const previous = record.gate
|
||||
record.gate = (argv) => {
|
||||
if (!argv.includes(argument)) return previous?.(argv)
|
||||
reached.resolve()
|
||||
return released.promise
|
||||
}
|
||||
return { reached: reached.promise, release: () => { released.resolve() } }
|
||||
}
|
||||
|
||||
/** Repository served by {@link FakeProvider}. */
|
||||
export const demo: ForgeRepository = { forge: 'fake', host: 'fake.test', owner: 'octo', name: 'demo' }
|
||||
|
||||
/** Canonical URL of a `demo` change request. */
|
||||
export function pullUrl(number: number): string {
|
||||
return `https://fake.test/octo/demo/pull/${number}`
|
||||
}
|
||||
|
||||
/** Open change request snapshot with settled checks. */
|
||||
export function snapshot(ref: ChangeRequestRef): ChangeRequestSnapshot {
|
||||
return {
|
||||
ref,
|
||||
url: pullUrl(ref.number),
|
||||
title: `Change ${ref.number}`,
|
||||
state: 'open',
|
||||
draft: false,
|
||||
author: 'octo',
|
||||
headBranch: 'feat',
|
||||
headSha: 'a'.repeat(40),
|
||||
baseBranch: 'main',
|
||||
additions: 12,
|
||||
deletions: 3,
|
||||
mergeability: 'mergeable',
|
||||
reviewDecision: 'none',
|
||||
checks: [],
|
||||
checksRollup: 'success',
|
||||
checksTruncated: false,
|
||||
unresolvedThreads: 0,
|
||||
updatedAt: '2026-10-09T00:00:00Z',
|
||||
closedAt: null,
|
||||
}
|
||||
}
|
||||
|
||||
/** Provider for `https://fake.test/<owner>/<name>.git` remotes and `https://fake.test/<owner>/<name>/pull/<n>` URLs. */
|
||||
export class FakeProvider implements ForgeProvider {
|
||||
readonly forge = 'fake'
|
||||
readonly fetches: (readonly number[])[] = []
|
||||
readonly probes: string[] = []
|
||||
/** Polling failures returned instead of snapshots, by change request number. */
|
||||
readonly failures = new Map<number, ForgeFailure>()
|
||||
readinessImpl: (host: string, signal: AbortSignal) => Promise<ForgeReadiness> = () => Promise.resolve({ state: 'ready', message: null })
|
||||
|
||||
repositoryFromRemote(url: string): ForgeRepository | undefined {
|
||||
const [, host = '', owner = '', name = ''] = /^https:\/\/([a-z.]+\.test)\/([^/]+)\/([^/]+?)\.git$/.exec(url) ?? []
|
||||
return host.length === 0 ? undefined : { forge: 'fake', host, owner, name }
|
||||
}
|
||||
|
||||
changeRequestFromUrl(url: string): ChangeRequestRef | undefined {
|
||||
const [, owner = '', name = '', number = ''] = /^https:\/\/fake\.test\/([^/]+)\/([^/]+)\/pull\/(\d+)$/.exec(url) ?? []
|
||||
return number.length === 0 ? undefined : { repository: { ...demo, owner, name }, number: Number(number) }
|
||||
}
|
||||
|
||||
changeRequestUrl(ref: ChangeRequestRef): string {
|
||||
return `https://fake.test/${ref.repository.owner}/${ref.repository.name}/pull/${ref.number}`
|
||||
}
|
||||
|
||||
fetch(refs: readonly ChangeRequestRef[]): Promise<ReadonlyMap<ChangeRequestKey, ChangeRequestSnapshot | ForgeFailure>> {
|
||||
this.fetches.push(refs.map(ref => ref.number))
|
||||
return Promise.resolve(new Map(refs.map(ref => [changeRequestKey(ref), this.failures.get(ref.number) ?? snapshot(ref)])))
|
||||
}
|
||||
|
||||
findByHead(): Promise<readonly ChangeRequestRef[]> {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
readiness(host: string, signal: AbortSignal): Promise<ForgeReadiness> {
|
||||
this.probes.push(host)
|
||||
return this.readinessImpl(host, signal)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An idle Agent double whose `followup` queues the message for the next turn and whose `steer` and `inject` queue it
|
||||
* for the next step, as the Agent loop does. Like the Agent loop, it runs one maintenance task at a time, refusing
|
||||
* another synchronously while one runs, and `cancel` aborts the running task's signal with its cause; it drives no
|
||||
* turns and leaves the inbox to the caller.
|
||||
* @param ctx - composition that owns the Agent scope.
|
||||
* @param session - Session the Agent drives.
|
||||
* @returns the Agent, not yet registered.
|
||||
*/
|
||||
export function testAgent(ctx: Context, session: Session): Agent {
|
||||
const scope = ctx.plugin(() => {})
|
||||
const inbox = createInboxStub()
|
||||
let maintenance: AbortController | undefined
|
||||
return {
|
||||
id: session.id, session, options: {}, status: 'idle', ctx: scope.ctx, inbox,
|
||||
send() {},
|
||||
followup(message) { inbox.append('next-turn', message) },
|
||||
steer(message) { inbox.append('next-step', message) },
|
||||
cancel(cause) { maintenance?.abort(cause) },
|
||||
inject(message) { inbox.append('next-step', message) },
|
||||
whenIdle: async () => {},
|
||||
runMaintenance(task) {
|
||||
if (maintenance !== undefined) throw new Error(`agent "${session.id}" already has active work`)
|
||||
const running = new AbortController()
|
||||
maintenance = running
|
||||
return task(running.signal).finally(() => { maintenance = undefined })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** A user prompt. */
|
||||
export function prompt(text: string): UserMessage {
|
||||
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver a user prompt for the next turn through the agent controller's admission, as the Web Session Remote does.
|
||||
* @param ctx - composition whose admission listeners decide.
|
||||
* @param agent - receiving Agent.
|
||||
* @param text - prompt text.
|
||||
* @param signal - the sender's cancellation; a fresh signal when omitted.
|
||||
* @returns settles once the prompt is in the Agent's inbox.
|
||||
*/
|
||||
export async function admitPrompt(ctx: Context, agent: Agent, text: string, signal = new AbortController().signal): Promise<void> {
|
||||
const message = prompt(text)
|
||||
await ctx.agentController.deliver(agent, { mode: 'followup', source: message.source, signal, prepare: async () => message.content })
|
||||
}
|
||||
|
||||
/**
|
||||
* Match the directory notice that entering `path` queues.
|
||||
* @param path - directory the notice names.
|
||||
* @returns an asymmetric matcher for the notice message.
|
||||
*/
|
||||
export function notice(path: string): unknown {
|
||||
const text: unknown = expect.stringContaining(JSON.stringify(path))
|
||||
return expect.objectContaining({ role: 'user', source: { kind: 'working-directory' }, content: [{ type: 'text', text }] })
|
||||
}
|
||||
|
||||
/** One step proposal of the Agent loop. */
|
||||
export interface StepProposal {
|
||||
readonly turn: number
|
||||
readonly step: number
|
||||
/** Messages removed from the inbox for the step; none when omitted. */
|
||||
readonly messages?: UserMessage[]
|
||||
/** Decision of the listeners after the panel's; entering with `messages` when omitted. */
|
||||
readonly decision?: PreStepDecision
|
||||
/** Turn cancellation; a fresh signal when omitted. */
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the `agent/pre-step` waterfall that the Agent loop runs before it enters a step.
|
||||
* @param ctx - composition whose listeners decide.
|
||||
* @param agent - Agent that proposes the step.
|
||||
* @param proposal - turn, step, messages, and the decision after the panel's listener.
|
||||
* @returns the decision the listeners compose.
|
||||
*/
|
||||
export async function proposeStep(ctx: Context, agent: Agent, proposal: StepProposal): Promise<PreStepDecision> {
|
||||
const messages = proposal.messages ?? []
|
||||
const decision: PreStepDecision = proposal.decision ?? { kind: 'enter', messages }
|
||||
return await agentEvents(ctx, agent).waterfall('agent/pre-step', {
|
||||
messages, turn: proposal.turn, step: proposal.step, signal: proposal.signal ?? new AbortController().signal,
|
||||
}, () => Promise.resolve(decision))
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the start of a turn, as the Agent loop does before it proposes the turn's first step.
|
||||
* @param session - live Session receiving the event.
|
||||
* @param turn - turn number.
|
||||
*/
|
||||
export function startTurn(session: Session, turn: number): void {
|
||||
session.append('turn/start', { turn })
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the start of a step, as the Agent loop does after an entered proposal.
|
||||
* @param session - live Session receiving the event.
|
||||
* @param turn - turn number.
|
||||
* @param step - step number within the turn.
|
||||
*/
|
||||
export function startStep(session: Session, turn: number, step = 1): void {
|
||||
session.append('step/start', { turn, step })
|
||||
}
|
||||
|
||||
/** A booted composition without the panel Remote, with one live Session and its idle Agent. */
|
||||
export interface Runtime {
|
||||
readonly ctx: Context
|
||||
readonly provider: FakeProvider
|
||||
readonly sandbox: SandboxRecord
|
||||
readonly session: Session
|
||||
readonly agent: Agent
|
||||
/** Data of the Session's worktree records in log order. */
|
||||
readonly records: unknown[]
|
||||
/** Fiber of the worktree service, or undefined when it is not mounted. */
|
||||
readonly worktrees: ReturnType<Context['plugin']> | undefined
|
||||
}
|
||||
|
||||
/** A {@link Runtime} with the panel Remote. */
|
||||
export interface Bench extends Runtime {
|
||||
/** Fiber of the panel Remote. */
|
||||
readonly panel: ReturnType<Context['plugin']>
|
||||
/** Reads the `forgeWorktree` projection without holding a registration, so the panel's own registration decides its lifetime. */
|
||||
readonly worktreeStates: ProjectionHandle<ForgeWorktreeState>
|
||||
}
|
||||
|
||||
/** Optional services of a {@link runtime} or {@link bench}. */
|
||||
export interface BenchOptions {
|
||||
/** Mount the worktree service. */
|
||||
readonly worktrees?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the Session, directory, process, forge, and binding services with a recording sandbox, and open one live Session.
|
||||
* @param cwd - Session project directory.
|
||||
* @param options - optional services.
|
||||
* @returns the composition without the panel Remote.
|
||||
*/
|
||||
export async function runtime(cwd: string, options: BenchOptions = {}): Promise<Runtime> {
|
||||
const ctx = new Context()
|
||||
const provider = new FakeProvider()
|
||||
const sandbox = sandboxRecord()
|
||||
try {
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentController)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(LocalFileSystem, { cwd })
|
||||
await ctx.plugin(LocalSubprocessRuntime)
|
||||
await ctx.plugin(RecordingSandbox, sandbox)
|
||||
await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: cwd })
|
||||
await ctx.plugin(WorkingDirectoryService)
|
||||
await ctx.plugin(ForgeService, { activeIntervalMs: 60_000, idleIntervalMs: 60_000, maxBackoffMs: 60_000, fetchTimeoutMs: 1_000 })
|
||||
await ctx.plugin({ inject: ['forge'], apply(child: Context) { child.forge.register(provider) } })
|
||||
await ctx.plugin(ForgeSessionService, {})
|
||||
const worktrees = options.worktrees === true ? ctx.plugin(WorktreeService, {}) : undefined
|
||||
if (worktrees !== undefined) await worktrees
|
||||
const session = ctx.sessions.create(SessionId(`forge-panel-${Math.random().toString(36).slice(2)}`), { meta: { cwd } })
|
||||
const records: unknown[] = []
|
||||
ctx.on('session/event', (target: Session, event: SessionEvent) => {
|
||||
const record = pluginRecordOf(event)
|
||||
if (target === session && record?.type === WORKTREE_RECORD) records.push(record.data)
|
||||
})
|
||||
const agent = testAgent(ctx, session)
|
||||
await ctx.agents.register(agent)
|
||||
return { ctx, provider, sandbox, session, agent, records, worktrees }
|
||||
} catch (error) {
|
||||
await ctx.fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a {@link runtime} and the panel Remote.
|
||||
* @param cwd - Session project directory.
|
||||
* @param config - panel Remote configuration.
|
||||
* @param options - optional services.
|
||||
* @returns the composition with one live Session.
|
||||
*/
|
||||
export async function bench(cwd: string, config: Config = {}, options: BenchOptions = {}): Promise<Bench> {
|
||||
const base = await runtime(cwd, options)
|
||||
const panel = base.ctx.plugin(ForgePanelController, config)
|
||||
try {
|
||||
await panel
|
||||
} catch (error) {
|
||||
await base.ctx.fiber.dispose()
|
||||
throw error
|
||||
}
|
||||
const worktreeStates = await base.ctx.sessionProjections.register(forgeWorktreeProjection)
|
||||
await worktreeStates.dispose()
|
||||
return { ...base, panel, worktreeStates }
|
||||
}
|
||||
|
||||
/** A panel stream reader that keeps every state it has pulled. */
|
||||
export interface Follower {
|
||||
readonly states: ForgePanelState[]
|
||||
/** Pull states until one satisfies `predicate`, checking the states already pulled first. */
|
||||
until: (predicate: (state: ForgePanelState) => boolean) => Promise<ForgePanelState>
|
||||
/** Pull states until one that this call pulls satisfies `predicate`. */
|
||||
next: (predicate: (state: ForgePanelState) => boolean) => Promise<ForgePanelState>
|
||||
/** Abort the stream and wait for it to end. */
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Start reading `ctx.forgePanel.follow(session)`.
|
||||
* @param ctx - composition that provides `forgePanel`.
|
||||
* @param session - Session to follow.
|
||||
* @returns the reader.
|
||||
*/
|
||||
export function follow(ctx: Context, session: Session): Follower {
|
||||
const controller = new AbortController()
|
||||
const iterator = ctx.forgePanel.follow(session, controller.signal)[Symbol.asyncIterator]()
|
||||
const states: ForgePanelState[] = []
|
||||
const pull = async (predicate: (state: ForgePanelState) => boolean): Promise<ForgePanelState> => {
|
||||
while (true) {
|
||||
const next = await iterator.next()
|
||||
if (next.done === true) throw new Error('panel stream ended')
|
||||
states.push(next.value)
|
||||
if (predicate(next.value)) return next.value
|
||||
}
|
||||
}
|
||||
return {
|
||||
states,
|
||||
async until(predicate) {
|
||||
for (const state of states) if (predicate(state)) return state
|
||||
return await pull(predicate)
|
||||
},
|
||||
next: pull,
|
||||
async stop() {
|
||||
controller.abort()
|
||||
await iterator.return?.()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append one completed turn in which the model runs `bash` commands.
|
||||
* @param session - live Session receiving the events.
|
||||
* @param turn - turn number.
|
||||
* @param calls - commands with their rendered results.
|
||||
*/
|
||||
export function shellTurn(session: Session, turn: number, calls: readonly { callId: string; command: string; output: string }[]): void {
|
||||
const step = 1
|
||||
const callArguments = (command: string) => JSON.stringify({ command })
|
||||
session.append('turn/start', { turn })
|
||||
session.append('step/start', { turn, step })
|
||||
session.append('assistant/message', {
|
||||
turn,
|
||||
step,
|
||||
stream: [],
|
||||
message: createAssistantMessage({
|
||||
content: calls.map(call => ({ type: 'tool-call' as const, id: ToolCallId(call.callId), name: 'bash', arguments: callArguments(call.command) })),
|
||||
source: { provider: 'fixture', model: 'fixture' },
|
||||
}),
|
||||
}, { surfaceOp: 'append' })
|
||||
for (const call of calls) session.append('tool/call', { turn, step, callId: ToolCallId(call.callId), name: 'bash', arguments: callArguments(call.command) })
|
||||
for (const call of calls) {
|
||||
session.append('tool/result', {
|
||||
turn,
|
||||
step,
|
||||
message: createToolResultMessage({ callId: ToolCallId(call.callId), content: [{ type: 'text', text: call.output }], isError: false }),
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
session.append('step/end', { turn, step })
|
||||
session.append('turn/end', { turn, reason: { kind: 'completed' } })
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Forge panel registration: the Remote mount, slot entries, per-Session stores, actions and their notices, and unload. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { SlotRegistry } from '@deepseek-ai/dsh-client-ui-renderer/client'
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import { RemoteError, type RemoteResult, type TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ActionToast } from '../src/client/ActionToast.tsx'
|
||||
import { BranchChip, type BranchChipInjected } from '../src/client/BranchChip.tsx'
|
||||
import { ForgeTab, type ForgeTabInjected } from '../src/client/ForgeTab.tsx'
|
||||
import { ForgeTabTitle } from '../src/client/ForgeTabTitle.tsx'
|
||||
import { HeaderButton, type HeaderButtonInjected } from '../src/client/HeaderButton.tsx'
|
||||
import { en, NS, zh } from '../src/client/locales.ts'
|
||||
import { inject, mountForgePanel, TAB_ID, TAB_KIND } from '../src/client/mount.ts'
|
||||
import type { NoticeSource } from '../src/client/source.ts'
|
||||
import { WorktreeProgress, type WorktreeProgressInjected } from '../src/client/WorktreeProgress.tsx'
|
||||
import { WorktreeToggle, type WorktreeToggleInjected } from '../src/client/WorktreeToggle.tsx'
|
||||
import type { ForgeBranches, ForgePanelState } from '../src/types.ts'
|
||||
import { entry, ready, snapshot, unserved } from './client-fixtures.client.ts'
|
||||
|
||||
const REMOTE: TypertRemoteContribution = { package: '@deepseek-ai/dsh-experimental-client-ui-forge', descriptors: [] }
|
||||
const S1 = SessionId('s1')
|
||||
const S2 = SessionId('s2')
|
||||
const BRANCHES: ForgeBranches = { current: 'main', branches: ['main', 'release'] }
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
Reflect.deleteProperty(navigator, 'clipboard')
|
||||
})
|
||||
|
||||
/** A fake `follow` stream handle the test feeds by hand. */
|
||||
function stream(signal: AbortSignal) {
|
||||
const queue: ForgePanelState[] = []
|
||||
let wake: (() => void) | undefined
|
||||
let closed = false
|
||||
const handle = {
|
||||
signal,
|
||||
disposed: vi.fn(),
|
||||
push(state: ForgePanelState) { queue.push(state); wake?.() },
|
||||
dispose() { handle.disposed(); closed = true; wake?.() },
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<ForgePanelState> {
|
||||
while (true) {
|
||||
const next = queue.shift()
|
||||
if (next !== undefined) { yield next; continue }
|
||||
if (closed) return
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
wake = undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
return handle
|
||||
}
|
||||
|
||||
const OK: RemoteResult<void> = { ok: true, value: undefined }
|
||||
|
||||
const REFUSED = {
|
||||
started: { ok: false, error: new RemoteError('forgePanel/session-started', 'started', {}) },
|
||||
unavailable: { ok: false, error: new RemoteError('forgePanel/worktrees-unavailable', 'unavailable', {}) },
|
||||
busy: { ok: false, error: new RemoteError('forgePanel/session-busy', 'busy', {}) },
|
||||
restore: { ok: false, error: new RemoteError('forgePanel/restore-failed', 'fatal: \'/work/repo-dsh\' already exists', {}) },
|
||||
failed: { ok: false, error: new RemoteError('gateway/bad-request', 'failed', {}) },
|
||||
} satisfies Record<string, RemoteResult<never>>
|
||||
|
||||
function isTabFace(value: Record<string, unknown>): value is Record<string, unknown> & ForgeTabInjected {
|
||||
return typeof value.reportVisible === 'function' && typeof value.copyText === 'function'
|
||||
}
|
||||
|
||||
function isHeaderFace(value: Record<string, unknown>): value is Record<string, unknown> & HeaderButtonInjected {
|
||||
return typeof value.toggle === 'function' && typeof value.hooks === 'object'
|
||||
}
|
||||
|
||||
function isWorktreeFace(value: Record<string, unknown>): value is Record<string, unknown> & WorktreeToggleInjected {
|
||||
return typeof value.request === 'function' && typeof value.branches === 'function'
|
||||
}
|
||||
|
||||
function isProgressFace(value: Record<string, unknown>): value is Record<string, unknown> & WorktreeProgressInjected {
|
||||
return typeof value.stop === 'function' && typeof value.withdraw === 'function'
|
||||
&& typeof value.restore === 'function' && typeof value.leave === 'function'
|
||||
}
|
||||
|
||||
function isBranchFace(value: Record<string, unknown>): value is Record<string, unknown> & BranchChipInjected {
|
||||
return typeof value.hooks === 'object' && Object.keys(value).length === 1
|
||||
}
|
||||
|
||||
function isNoticeFace(value: Record<string, unknown>): value is Record<string, unknown> & Omit<NoticeSource, 'report'> {
|
||||
return typeof value.dismiss === 'function' && typeof value.hooks === 'object'
|
||||
}
|
||||
|
||||
/** Let the Remote results that the actions await settle. */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
async function base() {
|
||||
const ctx = new Context()
|
||||
const unmount = vi.fn(async () => {})
|
||||
class Remote extends Service {
|
||||
constructor() { super(ctx, 'remote') }
|
||||
async $mount(contribution: TypertRemoteContribution) {
|
||||
expect(contribution).toBe(REMOTE)
|
||||
return unmount
|
||||
}
|
||||
}
|
||||
new Remote()
|
||||
const streams: ReturnType<typeof stream>[] = []
|
||||
const forgePanel = {
|
||||
follow: vi.fn((_sessionId: SessionId, signal: AbortSignal) => {
|
||||
const handle = stream(signal)
|
||||
streams.push(handle)
|
||||
return handle
|
||||
}),
|
||||
track: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
|
||||
dismiss: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
|
||||
refresh: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
requestWorktree: vi.fn(async (_sessionId: SessionId, _from: string | null): Promise<RemoteResult<void>> => OK),
|
||||
cancelWorktree: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
stopCreation: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
restoreWorktree: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
continueInProject: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<void>> => OK),
|
||||
branches: vi.fn(async (_sessionId: SessionId): Promise<RemoteResult<ForgeBranches>> => ({ ok: true, value: BRANCHES })),
|
||||
}
|
||||
ctx.provide('remote.forgePanel', forgePanel)
|
||||
ctx.provide('locale', new LocaleRuntime(ctx))
|
||||
const unregisterTab = vi.fn()
|
||||
const sidebarRightTabs = { register: vi.fn((_type: { id: string; kind: string; title: () => string }) => unregisterTab) }
|
||||
ctx.provide('sidebarRightTabs', sidebarRightTabs)
|
||||
const sidebarRight = { openTab: vi.fn(), toggleExpanded: vi.fn() }
|
||||
ctx.provide('sidebarRight', sidebarRight)
|
||||
await ctx.plugin(SlotRegistry)
|
||||
ctx.slots.register({
|
||||
name: 'root',
|
||||
children: {
|
||||
'sidebar.right.pane.tab': { kind: 'keyed', scope: 'session' },
|
||||
'sidebar.right.pane.tab.title': { kind: 'keyed', scope: 'session' },
|
||||
'conversation.session.header.utilities': { kind: 'list', scope: 'session' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
'conversation.hero.options': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.dock': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.left': { kind: 'list', scope: 'session' },
|
||||
'shell.overlay': { kind: 'list', scope: 'root' },
|
||||
},
|
||||
} as never, () => null)
|
||||
return { ctx, unmount, streams, forgePanel, sidebarRightTabs, unregisterTab, sidebarRight }
|
||||
}
|
||||
|
||||
async function fixture() {
|
||||
const b = await base()
|
||||
let dispose: (() => Promise<void>) | undefined
|
||||
await b.ctx.plugin({ inject: [...inject], apply: async (plugin: Context) => { dispose = await mountForgePanel(plugin, REMOTE) } })
|
||||
assert(dispose !== undefined)
|
||||
const entry = (slot: Parameters<typeof b.ctx.slots.entries>[0], component: unknown) =>
|
||||
b.ctx.slots.entries(slot).find(candidate => candidate.component === component)
|
||||
const tabInject = entry('sidebar.right.pane.tab', ForgeTab)?.inject
|
||||
const headerInject = entry('conversation.session.header.utilities', HeaderButton)?.inject
|
||||
const worktreeInject = entry('conversation.hero.options', WorktreeToggle)?.inject
|
||||
const progressInject = entry('conversation.input.dock', WorktreeProgress)?.inject
|
||||
const branchInject = entry('conversation.session.header.actions', BranchChip)?.inject
|
||||
const toastInject = entry('shell.overlay', ActionToast)?.inject
|
||||
assert(tabInject !== undefined && headerInject !== undefined && worktreeInject !== undefined && toastInject !== undefined)
|
||||
assert(progressInject !== undefined && branchInject !== undefined)
|
||||
const tab = (sessionId: SessionId): ForgeTabInjected => {
|
||||
const value = tabInject(sessionId as never)
|
||||
assert(isTabFace(value))
|
||||
return value
|
||||
}
|
||||
const header = (sessionId: SessionId): HeaderButtonInjected => {
|
||||
const value = headerInject(sessionId as never)
|
||||
assert(isHeaderFace(value))
|
||||
return value
|
||||
}
|
||||
const worktree = (sessionId: SessionId): WorktreeToggleInjected => {
|
||||
const value = worktreeInject(sessionId as never)
|
||||
assert(isWorktreeFace(value))
|
||||
return value
|
||||
}
|
||||
const progress = (sessionId: SessionId): WorktreeProgressInjected => {
|
||||
const value = progressInject(sessionId as never)
|
||||
assert(isProgressFace(value))
|
||||
return value
|
||||
}
|
||||
const branch = (sessionId: SessionId): BranchChipInjected => {
|
||||
const value = branchInject(sessionId as never)
|
||||
assert(isBranchFace(value))
|
||||
return value
|
||||
}
|
||||
const toast = (): Omit<NoticeSource, 'report'> => {
|
||||
const value = toastInject(undefined as never)
|
||||
assert(isNoticeFace(value))
|
||||
return value
|
||||
}
|
||||
const notice = () => toast().hooks.notice.getSnapshot()
|
||||
return { ...b, dispose, entry, tab, header, worktree, progress, branch, toast, notice }
|
||||
}
|
||||
|
||||
describe('mountForgePanel registration', () => {
|
||||
it('declares the browser services it needs', () => {
|
||||
expect(inject).toEqual(['remote', 'slots', 'locale', 'sidebarRightTabs', 'sidebarRight'])
|
||||
})
|
||||
|
||||
it('registers the tab type, its body and title, the header entries, the worktree option and card, and the toast', async () => {
|
||||
const f = await fixture()
|
||||
expect(f.sidebarRightTabs.register).toHaveBeenCalledOnce()
|
||||
const type = f.sidebarRightTabs.register.mock.calls[0]?.[0]
|
||||
expect(type).toMatchObject({ id: TAB_ID, kind: TAB_KIND })
|
||||
expect([en['tab.title'], zh['tab.title']]).toContain(type?.title())
|
||||
expect(f.entry('sidebar.right.pane.tab', ForgeTab)).toMatchObject({ options: { key: TAB_ID }, locale: NS })
|
||||
expect(f.entry('sidebar.right.pane.tab.title', ForgeTabTitle)).toMatchObject({ options: { key: TAB_ID }, locale: NS })
|
||||
expect(f.entry('conversation.session.header.utilities', HeaderButton)).toMatchObject({ options: { id: 'forge-panel', order: -8 }, locale: NS })
|
||||
expect(f.entry('conversation.session.header.actions', BranchChip)).toMatchObject({ options: { id: 'forge-panel.branch', order: 10 }, locale: NS })
|
||||
expect(f.entry('conversation.hero.options', WorktreeToggle)).toMatchObject({ options: { id: 'forge-panel.worktree' }, locale: NS })
|
||||
expect(f.entry('conversation.input.dock', WorktreeProgress)).toMatchObject({ options: { id: 'forge-panel.worktree', order: 5 }, locale: NS })
|
||||
expect(f.ctx.slots.entries('conversation.input.left')).toEqual([])
|
||||
expect(f.entry('shell.overlay', ActionToast)).toMatchObject({ options: { id: 'forge-panel.toast' }, locale: NS })
|
||||
expect(f.notice()).toBeNull()
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('withdraws every entry, closes the streams, and unmounts the Remote on unload', async () => {
|
||||
const f = await fixture()
|
||||
f.tab(S1).hooks.panel.subscribe(() => {})
|
||||
f.header(S2)
|
||||
expect(f.streams).toHaveLength(1)
|
||||
|
||||
await f.dispose()
|
||||
|
||||
expect(f.streams[0]?.disposed).toHaveBeenCalled()
|
||||
expect(f.streams[0]?.signal.aborted).toBe(true)
|
||||
expect(f.unregisterTab).toHaveBeenCalled()
|
||||
expect(f.unmount).toHaveBeenCalledOnce()
|
||||
for (const [slot, component] of [
|
||||
['sidebar.right.pane.tab', ForgeTab],
|
||||
['sidebar.right.pane.tab.title', ForgeTabTitle],
|
||||
['conversation.session.header.utilities', HeaderButton],
|
||||
['conversation.session.header.actions', BranchChip],
|
||||
['conversation.hero.options', WorktreeToggle],
|
||||
['conversation.input.dock', WorktreeProgress],
|
||||
['shell.overlay', ActionToast],
|
||||
] as const) expect(f.entry(slot, component)).toBeUndefined()
|
||||
f.ctx.emit('connection/reset')
|
||||
expect(f.forgePanel.follow).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('unmounts the Remote when the UI registration fails', async () => {
|
||||
const b = await base()
|
||||
vi.spyOn(b.ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot failed') })
|
||||
const fiber = b.ctx.plugin({ inject: [...inject], apply: (plugin: Context) => mountForgePanel(plugin, REMOTE) })
|
||||
await expect(fiber).rejects.toThrow('slot failed')
|
||||
expect(b.unmount).toHaveBeenCalledOnce()
|
||||
expect(b.unregisterTab).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel Session stores', () => {
|
||||
it('shares one panel stream per Session between the tab, the header entries, the worktree option, and the worktree card', async () => {
|
||||
const f = await fixture()
|
||||
const tab = f.tab(S1)
|
||||
expect(f.header(S1).hooks.panel).toBe(tab.hooks.panel)
|
||||
expect(f.branch(S1).hooks.panel).toBe(tab.hooks.panel)
|
||||
expect(f.worktree(S1).hooks.panel).toBe(tab.hooks.panel)
|
||||
expect(f.progress(S1).hooks.panel).toBe(tab.hooks.panel)
|
||||
expect(f.tab(S2).hooks.panel).not.toBe(tab.hooks.panel)
|
||||
expect(f.forgePanel.follow).not.toHaveBeenCalled()
|
||||
|
||||
const seen: unknown[] = []
|
||||
const unsubscribe = tab.hooks.panel.subscribe(() => { seen.push(tab.hooks.panel.getSnapshot().state) })
|
||||
expect(f.forgePanel.follow).toHaveBeenCalledWith(S1, expect.any(AbortSignal))
|
||||
f.streams[0]?.push(ready())
|
||||
await vi.waitFor(() => { expect(seen).toEqual([ready()]) })
|
||||
|
||||
tab.reconnect()
|
||||
expect(f.forgePanel.follow).toHaveBeenCalledOnce()
|
||||
f.ctx.emit('connection/reset')
|
||||
expect(f.streams[0]?.disposed).toHaveBeenCalled()
|
||||
expect(f.forgePanel.follow).toHaveBeenCalledTimes(2)
|
||||
unsubscribe()
|
||||
expect(f.streams[1]?.disposed).toHaveBeenCalled()
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('shares the tab visibility of a Session with its header entry', async () => {
|
||||
const f = await fixture()
|
||||
const visible = f.header(S1).hooks.visible
|
||||
const listener = vi.fn()
|
||||
visible.subscribe(listener)
|
||||
expect(visible.getSnapshot()).toBe(false)
|
||||
|
||||
f.tab(S1).reportVisible(true)
|
||||
expect(visible.getSnapshot()).toBe(true)
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(f.header(S2).hooks.visible.getSnapshot()).toBe(false)
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('reveals the hidden tab from the header entry and collapses the sidebar showing it', async () => {
|
||||
const f = await fixture()
|
||||
f.header(S1).toggle(false)
|
||||
expect(f.sidebarRight.openTab).toHaveBeenCalledWith(TAB_KIND)
|
||||
expect(f.sidebarRight.toggleExpanded).not.toHaveBeenCalled()
|
||||
f.header(S1).toggle(true)
|
||||
expect(f.sidebarRight.toggleExpanded).toHaveBeenCalledOnce()
|
||||
expect(f.sidebarRight.openTab).toHaveBeenCalledOnce()
|
||||
await f.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel tab actions', () => {
|
||||
it('opens links outside the app', async () => {
|
||||
const f = await fixture()
|
||||
const open = vi.spyOn(window, 'open').mockReturnValue(null)
|
||||
f.tab(S1).openLink('https://github.com/octo/demo/pull/1')
|
||||
expect(open).toHaveBeenCalledWith('https://github.com/octo/demo/pull/1', '_blank', 'noopener,noreferrer')
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['confirms a completed copy', async () => {}, { key: 'toast.copied', tone: 'success' }],
|
||||
['reports a refused copy', async () => { throw new Error('denied') }, { key: 'toast.copyFailed', tone: 'failure' }],
|
||||
])('copies text to the clipboard and %s', async (_label, write, outcome) => {
|
||||
const f = await fixture()
|
||||
const writeText = vi.fn(async (_text: string) => { await write() })
|
||||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
|
||||
f.tab(S1).copyText('gh auth login --hostname github.com')
|
||||
await settle()
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith('gh auth login --hostname github.com')
|
||||
expect(f.notice()).toMatchObject({ ...outcome, params: {}, undo: undefined })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('refreshes through the Remote and reports only a failure', async () => {
|
||||
const f = await fixture()
|
||||
f.tab(S1).refresh()
|
||||
await settle()
|
||||
expect(f.forgePanel.refresh).toHaveBeenCalledWith(S1)
|
||||
expect(f.notice()).toBeNull()
|
||||
|
||||
f.forgePanel.refresh.mockResolvedValueOnce(REFUSED.failed)
|
||||
f.tab(S1).refresh()
|
||||
await settle()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.refreshFailed', tone: 'failure' })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('shows the notices the tab reports until they are dismissed', async () => {
|
||||
const f = await fixture()
|
||||
f.tab(S1).report('toast.chatFailed')
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.chatFailed', tone: 'failure', seq: 1 })
|
||||
f.toast().dismiss()
|
||||
expect(f.notice()).toBeNull()
|
||||
await f.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel removal', () => {
|
||||
it('removes a change request and offers to restore it', async () => {
|
||||
const f = await fixture()
|
||||
const value = entry(7, snapshot(7))
|
||||
f.tab(S1).remove(value)
|
||||
await settle()
|
||||
expect(f.forgePanel.dismiss).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7')
|
||||
const removed = f.notice()
|
||||
expect(removed).toMatchObject({ key: 'toast.removed', tone: 'success', params: { name: '#7' } })
|
||||
|
||||
removed?.undo?.()
|
||||
await settle()
|
||||
expect(f.forgePanel.track).toHaveBeenCalledWith(S1, 'https://github.com/octo/demo/pull/7')
|
||||
expect(f.notice()).toBe(removed)
|
||||
|
||||
f.forgePanel.track.mockResolvedValueOnce(REFUSED.failed)
|
||||
removed?.undo?.()
|
||||
await settle()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.undoFailed', tone: 'failure' })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['its URL when it has neither a number nor a snapshot', unserved('https://example.test/review/latest'),
|
||||
'https://example.test/review/latest'],
|
||||
['its title when it has no number', { ...unserved('https://example.test/review/latest'), snapshot: snapshot(3) }, 'Change 3'],
|
||||
['the number in its URL', unserved('https://example.test/review/17'), '#17'],
|
||||
])('names a removed unserved change request by %s, without undo', async (_label, value, name) => {
|
||||
const f = await fixture()
|
||||
f.tab(S1).remove(value)
|
||||
await settle()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.removed', tone: 'success', params: { name }, undo: undefined })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('reports a failed removal without offering undo', async () => {
|
||||
const f = await fixture()
|
||||
f.forgePanel.dismiss.mockResolvedValueOnce(REFUSED.failed)
|
||||
f.tab(S1).remove(entry(7, snapshot(7)))
|
||||
await settle()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.removeFailed', tone: 'failure', params: {}, undo: undefined })
|
||||
await f.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel worktree actions', () => {
|
||||
it('requests and withdraws a worktree through the Remote', async () => {
|
||||
const f = await fixture()
|
||||
const option = f.worktree(S1)
|
||||
expect(await option.request('release')).toBe(true)
|
||||
expect(await option.request(null)).toBe(true)
|
||||
expect(await option.cancel()).toBe(true)
|
||||
expect(f.forgePanel.requestWorktree.mock.calls).toEqual([[S1, 'release'], [S1, null]])
|
||||
expect(f.forgePanel.cancelWorktree).toHaveBeenCalledWith(S1)
|
||||
expect(f.notice()).toBeNull()
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a started Session', REFUSED.started, 'toast.worktreeStarted'],
|
||||
['a missing worktree service', REFUSED.unavailable, 'toast.worktreeFailed'],
|
||||
['any other failure', REFUSED.failed, 'toast.worktreeFailed'],
|
||||
])('reports a request or withdrawal refused for %s', async (_label, refusal, key) => {
|
||||
const f = await fixture()
|
||||
const option = f.worktree(S1)
|
||||
f.forgePanel.requestWorktree.mockResolvedValueOnce(refusal)
|
||||
expect(await option.request('main')).toBe(false)
|
||||
expect(f.notice()).toMatchObject({ key, tone: 'failure', seq: 1 })
|
||||
f.forgePanel.cancelWorktree.mockResolvedValueOnce(refusal)
|
||||
expect(await option.cancel()).toBe(false)
|
||||
expect(f.notice()).toMatchObject({ key, tone: 'failure', seq: 2 })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('lists the branches of the Session checkout and reports when they cannot be read', async () => {
|
||||
const f = await fixture()
|
||||
expect(await f.worktree(S1).branches()).toEqual(BRANCHES)
|
||||
expect(f.forgePanel.branches).toHaveBeenCalledWith(S1)
|
||||
expect(f.notice()).toBeNull()
|
||||
|
||||
f.forgePanel.branches.mockResolvedValueOnce(REFUSED.failed)
|
||||
expect(await f.worktree(S1).branches()).toBeUndefined()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.branchesFailed', tone: 'failure' })
|
||||
await f.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mountForgePanel worktree card actions', () => {
|
||||
it.each([
|
||||
['stops the creation that holds the message', 'stop' as const, 'stopCreation' as const],
|
||||
['withdraws the request after a failed creation', 'withdraw' as const, 'cancelWorktree' as const],
|
||||
])('%s through the Remote and reports only a failure', async (_label, action, method) => {
|
||||
const f = await fixture()
|
||||
f.progress(S1)[action]()
|
||||
await settle()
|
||||
expect(f.forgePanel[method]).toHaveBeenCalledWith(S1)
|
||||
expect(f.notice()).toBeNull()
|
||||
|
||||
f.forgePanel[method].mockResolvedValueOnce(REFUSED.busy)
|
||||
f.progress(S1)[action]()
|
||||
await settle()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.worktreeFailed', tone: 'failure' })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('restores a removed worktree through the Remote and confirms it', async () => {
|
||||
const f = await fixture()
|
||||
expect(await f.progress(S1).restore()).toBeUndefined()
|
||||
expect(f.forgePanel.restoreWorktree).toHaveBeenCalledWith(S1)
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.restored', tone: 'success' })
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('returns why a restore failed for the card to show, without a notice', async () => {
|
||||
const f = await fixture()
|
||||
f.forgePanel.restoreWorktree.mockResolvedValueOnce(REFUSED.restore)
|
||||
expect(await f.progress(S1).restore()).toBe('fatal: \'/work/repo-dsh\' already exists')
|
||||
expect(f.notice()).toBeNull()
|
||||
await f.dispose()
|
||||
})
|
||||
|
||||
it('continues in the project through the Remote and reports only a failure', async () => {
|
||||
const f = await fixture()
|
||||
f.progress(S1).leave()
|
||||
await settle()
|
||||
expect(f.forgePanel.continueInProject).toHaveBeenCalledWith(S1)
|
||||
expect(f.notice()).toBeNull()
|
||||
|
||||
f.forgePanel.continueInProject.mockResolvedValueOnce(REFUSED.busy)
|
||||
f.progress(S1).leave()
|
||||
await settle()
|
||||
expect(f.notice()).toMatchObject({ key: 'toast.leaveFailed', tone: 'failure' })
|
||||
await f.dispose()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { baseBranches, parseName, parseRemotes, parseTopLevel, remoteOrder } from '../src/parse.ts'
|
||||
import type { GitRemote } from '../src/parse.ts'
|
||||
|
||||
describe('parseTopLevel', () => {
|
||||
it('reads the first line', () => {
|
||||
expect(parseTopLevel('/work/repo\n')).toBe('/work/repo')
|
||||
expect(parseTopLevel('C:/work/repo\r\n')).toBe('C:/work/repo')
|
||||
expect(parseTopLevel('/work/repo/.git\nignored\n')).toBe('/work/repo/.git')
|
||||
})
|
||||
|
||||
it('rejects empty output', () => {
|
||||
expect(() => parseTopLevel('')).toThrow('git rev-parse printed no directory')
|
||||
expect(() => parseTopLevel('\n')).toThrow('git rev-parse printed no directory')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseName', () => {
|
||||
it('reads the first line', () => {
|
||||
expect(parseName('main\n')).toBe('main')
|
||||
expect(parseName('origin/feat/x\r\nignored\n')).toBe('origin/feat/x')
|
||||
})
|
||||
|
||||
it('reads no name from empty output', () => {
|
||||
expect(parseName('')).toBeNull()
|
||||
expect(parseName('\n')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('baseBranches', () => {
|
||||
it('puts the checked-out branch first without repeating it', () => {
|
||||
expect(baseBranches('feat', 'main\nfeat\nfix\n', 10)).toEqual(['feat', 'main', 'fix'])
|
||||
})
|
||||
|
||||
it('keeps the checked-out branch when the listing omits it', () => {
|
||||
expect(baseBranches('old', 'main\nfeat\n', 2)).toEqual(['old', 'main'])
|
||||
expect(baseBranches('main', '', 50)).toEqual(['main'])
|
||||
})
|
||||
|
||||
it('lists the branches of a detached HEAD in listed order', () => {
|
||||
expect(baseBranches(null, 'main\r\nfeat\r\n', 10)).toEqual(['main', 'feat'])
|
||||
expect(baseBranches(null, '', 10)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns at most `limit` names and skips empty lines', () => {
|
||||
expect(baseBranches('main', '\nmain\n\nfeat\nfix\n', 2)).toEqual(['main', 'feat'])
|
||||
expect(baseBranches(null, 'main\nfeat\nfix\n', 1)).toEqual(['main'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseRemotes', () => {
|
||||
it('groups fetch and push URLs per remote in listed order', () => {
|
||||
const output = [
|
||||
'origin\tgit@github.com:octo/demo.git (push)',
|
||||
'origin\thttps://github.com/octo/demo.git (fetch)',
|
||||
'upstream\thttps://github.com/upstream/demo.git (fetch)',
|
||||
'upstream\thttps://github.com/upstream/demo.git (push)',
|
||||
'mirror\thttps://mirror.example/demo.git (push)',
|
||||
'not a remote line',
|
||||
'',
|
||||
].join('\r\n')
|
||||
|
||||
expect(parseRemotes(output)).toEqual([
|
||||
{ name: 'origin', urls: ['https://github.com/octo/demo.git', 'git@github.com:octo/demo.git'] },
|
||||
{ name: 'upstream', urls: ['https://github.com/upstream/demo.git'] },
|
||||
{ name: 'mirror', urls: ['https://mirror.example/demo.git'] },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('remoteOrder', () => {
|
||||
const remote = (name: string): GitRemote => ({ name, urls: [`https://example.test/${name}.git`] })
|
||||
|
||||
it('puts the upstream remote first, preferring the longest matching name, then origin', () => {
|
||||
const remotes = [remote('fork'), remote('origin'), remote('team'), remote('team/mirror')]
|
||||
|
||||
expect(remoteOrder('team/mirror/main', remotes).map(entry => entry.name)).toEqual(['team/mirror', 'origin', 'fork', 'team'])
|
||||
expect(remoteOrder('team/main', remotes).map(entry => entry.name)).toEqual(['team', 'origin', 'fork', 'team/mirror'])
|
||||
expect(remoteOrder('team/mirror/main', [remote('team/mirror'), remote('team')]).map(entry => entry.name)).toEqual(['team/mirror', 'team'])
|
||||
})
|
||||
|
||||
it('falls back to origin without an upstream or a matching remote', () => {
|
||||
const remotes = [remote('fork'), remote('origin')]
|
||||
|
||||
expect(remoteOrder(null, remotes).map(entry => entry.name)).toEqual(['origin', 'fork'])
|
||||
expect(remoteOrder('elsewhere/main', remotes).map(entry => entry.name)).toEqual(['origin', 'fork'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createNoticeSource, createPanelSource, createVisibilitySource } from '../src/client/source.ts'
|
||||
import type { PanelStream } from '../src/client/source.ts'
|
||||
import type { ForgePanelState } from '../src/types.ts'
|
||||
import { entry, ready, snapshot } from './client-fixtures.client.ts'
|
||||
|
||||
/** A stream the test feeds by hand. */
|
||||
function feed() {
|
||||
const queue: ForgePanelState[] = []
|
||||
let wake: (() => void) | undefined
|
||||
let closed = false
|
||||
let failure: unknown
|
||||
const stream = {
|
||||
signal: undefined as AbortSignal | undefined,
|
||||
disposed: vi.fn(),
|
||||
push(state: ForgePanelState) { queue.push(state); wake?.() },
|
||||
fail(error: unknown) { failure = error; wake?.() },
|
||||
close() { closed = true; wake?.() },
|
||||
dispose() { stream.disposed(); closed = true; wake?.() },
|
||||
async *[Symbol.asyncIterator](): AsyncIterator<ForgePanelState> {
|
||||
while (true) {
|
||||
const next = queue.shift()
|
||||
if (next !== undefined) { yield next; continue }
|
||||
if (failure !== undefined) throw failure
|
||||
if (closed) return
|
||||
await new Promise<void>((resolve) => { wake = resolve })
|
||||
wake = undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
function source() {
|
||||
const streams: ReturnType<typeof feed>[] = []
|
||||
const resets = new Set<() => void>()
|
||||
const panel = createPanelSource({
|
||||
open: (signal): PanelStream => {
|
||||
const stream = feed()
|
||||
stream.signal = signal
|
||||
streams.push(stream)
|
||||
return stream
|
||||
},
|
||||
subscribeReset: (listener) => {
|
||||
resets.add(listener)
|
||||
return () => { resets.delete(listener) }
|
||||
},
|
||||
})
|
||||
return { panel, streams, reset: () => { for (const listener of [...resets]) listener() }, resets }
|
||||
}
|
||||
|
||||
/** Let the stream readers run every iteration that is ready. */
|
||||
async function settle(): Promise<void> {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
|
||||
describe('createPanelSource', () => {
|
||||
it('opens one stream for all subscribers and closes it with the last one', async () => {
|
||||
const { panel, streams, resets } = source()
|
||||
const observable = panel.hooks.panel
|
||||
expect(streams).toHaveLength(0)
|
||||
expect(observable.getSnapshot()).toEqual({ state: null, disconnected: false })
|
||||
const seen: unknown[] = []
|
||||
const first = observable.subscribe(() => { seen.push(observable.getSnapshot()) })
|
||||
const second = observable.subscribe(() => {})
|
||||
expect(streams).toHaveLength(1)
|
||||
expect(resets.size).toBe(1)
|
||||
|
||||
streams[0]?.push(ready())
|
||||
await vi.waitFor(() => { expect(seen).toEqual([{ state: ready(), disconnected: false }]) })
|
||||
first()
|
||||
expect(streams[0]?.disposed).not.toHaveBeenCalled()
|
||||
second()
|
||||
expect(streams[0]?.disposed).toHaveBeenCalled()
|
||||
expect(streams[0]?.signal?.aborted).toBe(true)
|
||||
expect(resets.size).toBe(0)
|
||||
expect(observable.getSnapshot()).toEqual({ state: ready(), disconnected: false })
|
||||
|
||||
const again = observable.subscribe(() => {})
|
||||
expect(streams).toHaveLength(2)
|
||||
again()
|
||||
})
|
||||
|
||||
it('publishes every state of the running stream in order', async () => {
|
||||
const { panel, streams } = source()
|
||||
const seen: (ForgePanelState | null)[] = []
|
||||
const unsubscribe = panel.hooks.panel.subscribe(() => { seen.push(panel.hooks.panel.getSnapshot().state) })
|
||||
const later = ready({ changeRequests: [entry(1, snapshot(1))] })
|
||||
|
||||
streams[0]?.push(ready())
|
||||
streams[0]?.push(later)
|
||||
await vi.waitFor(() => { expect(seen).toEqual([ready(), later]) })
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('ignores frames from a stream it closed', async () => {
|
||||
const { panel, streams } = source()
|
||||
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
||||
const stream = streams[0]
|
||||
unsubscribe()
|
||||
stream?.push(ready({ directory: '/late' }))
|
||||
await settle()
|
||||
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['ends', (stream: ReturnType<typeof feed>) => { stream.close() }],
|
||||
['fails', (stream: ReturnType<typeof feed>) => { stream.fail(new Error('lost')) }],
|
||||
])('marks the snapshot disconnected when the stream %s and reopens on demand', async (_label, end) => {
|
||||
const { panel, streams } = source()
|
||||
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
||||
streams[0]?.push(ready())
|
||||
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot().state).not.toBeNull() })
|
||||
|
||||
end(streams[0]!)
|
||||
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot().disconnected).toBe(true) })
|
||||
expect(panel.hooks.panel.getSnapshot().state).toEqual(ready())
|
||||
panel.reconnect()
|
||||
expect(streams).toHaveLength(2)
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: false })
|
||||
panel.reconnect()
|
||||
expect(streams).toHaveLength(2)
|
||||
unsubscribe()
|
||||
panel.reconnect()
|
||||
expect(streams).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('lets the next subscriber reopen a stream that ended after the last one left', async () => {
|
||||
const { panel, streams, resets } = source()
|
||||
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
||||
streams[0]?.push(ready())
|
||||
streams[0]?.close()
|
||||
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: true }) })
|
||||
|
||||
unsubscribe()
|
||||
expect(resets.size).toBe(0)
|
||||
expect(streams[0]?.disposed).not.toHaveBeenCalled()
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: true })
|
||||
|
||||
const again = panel.hooks.panel.subscribe(() => {})
|
||||
expect(streams).toHaveLength(2)
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: false })
|
||||
again()
|
||||
})
|
||||
|
||||
it('replaces the stream after a transport reset and ignores the replaced one', async () => {
|
||||
const { panel, streams, reset } = source()
|
||||
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
||||
|
||||
reset()
|
||||
|
||||
expect(streams).toHaveLength(2)
|
||||
expect(streams[0]?.disposed).toHaveBeenCalled()
|
||||
expect(streams[0]?.signal?.aborted).toBe(true)
|
||||
streams[0]?.push(ready({ directory: '/stale' }))
|
||||
await settle()
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
||||
streams[1]?.push(ready())
|
||||
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot()).toEqual({ state: ready(), disconnected: false }) })
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('reopens a stream that ended when the transport resets', async () => {
|
||||
const { panel, streams, reset } = source()
|
||||
const unsubscribe = panel.hooks.panel.subscribe(() => {})
|
||||
streams[0]?.fail(new Error('lost'))
|
||||
await vi.waitFor(() => { expect(panel.hooks.panel.getSnapshot().disconnected).toBe(true) })
|
||||
|
||||
reset()
|
||||
|
||||
expect(streams).toHaveLength(2)
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
||||
unsubscribe()
|
||||
})
|
||||
|
||||
it('closes the stream and drops every subscriber on close', async () => {
|
||||
const { panel, streams, resets } = source()
|
||||
const first = vi.fn()
|
||||
const second = vi.fn()
|
||||
const unsubscribe = panel.hooks.panel.subscribe(first)
|
||||
panel.hooks.panel.subscribe(second)
|
||||
|
||||
panel.close()
|
||||
|
||||
expect(streams).toHaveLength(1)
|
||||
expect(streams[0]?.disposed).toHaveBeenCalledOnce()
|
||||
expect(streams[0]?.signal?.aborted).toBe(true)
|
||||
expect(resets.size).toBe(0)
|
||||
panel.reconnect()
|
||||
expect(streams).toHaveLength(1)
|
||||
unsubscribe()
|
||||
panel.close()
|
||||
streams[0]?.push(ready())
|
||||
await settle()
|
||||
expect(first).not.toHaveBeenCalled()
|
||||
expect(second).not.toHaveBeenCalled()
|
||||
expect(streams[0]?.disposed).toHaveBeenCalledOnce()
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
||||
})
|
||||
|
||||
it('closes a source nobody subscribed to', () => {
|
||||
const { panel, streams } = source()
|
||||
panel.close()
|
||||
expect(streams).toHaveLength(0)
|
||||
expect(panel.hooks.panel.getSnapshot()).toEqual({ state: null, disconnected: false })
|
||||
})
|
||||
})
|
||||
|
||||
describe('createNoticeSource', () => {
|
||||
it('publishes numbered failure notices without values or undo by default, and their dismissal', () => {
|
||||
const notices = createNoticeSource()
|
||||
expect(notices.hooks.notice.getSnapshot()).toBeNull()
|
||||
const seen: unknown[] = []
|
||||
const unsubscribe = notices.hooks.notice.subscribe(() => { seen.push(notices.hooks.notice.getSnapshot()) })
|
||||
|
||||
notices.report('toast.refreshFailed')
|
||||
notices.report('toast.refreshFailed')
|
||||
notices.dismiss()
|
||||
unsubscribe()
|
||||
notices.report('toast.copyFailed')
|
||||
|
||||
const failure = { key: 'toast.refreshFailed', params: {}, tone: 'failure', undo: undefined }
|
||||
expect(seen).toEqual([{ ...failure, seq: 1 }, { ...failure, seq: 2 }, null])
|
||||
expect(notices.hooks.notice.getSnapshot()).toEqual({ ...failure, key: 'toast.copyFailed', seq: 3 })
|
||||
})
|
||||
|
||||
it('carries the tone, values, and undo action a report names', () => {
|
||||
const notices = createNoticeSource()
|
||||
const undo = vi.fn()
|
||||
|
||||
notices.report('toast.removed', { tone: 'success', params: { name: '#7' }, undo })
|
||||
|
||||
expect(notices.hooks.notice.getSnapshot()).toEqual({ key: 'toast.removed', params: { name: '#7' }, tone: 'success', undo, seq: 1 })
|
||||
expect(undo).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createVisibilitySource', () => {
|
||||
it('starts hidden and notifies only when the visibility changes', () => {
|
||||
const visibility = createVisibilitySource()
|
||||
const listener = vi.fn()
|
||||
const unsubscribe = visibility.hooks.visible.subscribe(listener)
|
||||
expect(visibility.hooks.visible.getSnapshot()).toBe(false)
|
||||
|
||||
visibility.set(false)
|
||||
expect(listener).not.toHaveBeenCalled()
|
||||
visibility.set(true)
|
||||
visibility.set(true)
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(visibility.hooks.visible.getSnapshot()).toBe(true)
|
||||
|
||||
unsubscribe()
|
||||
visibility.set(false)
|
||||
expect(listener).toHaveBeenCalledOnce()
|
||||
expect(visibility.hooks.visible.getSnapshot()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
checkCounts, checkDuration, ciSummary, flagOf, needsAttention, numberOf, toneOf,
|
||||
} from '../src/client/status.ts'
|
||||
import { check, entry, ref, snapshot, unserved } from './client-fixtures.client.ts'
|
||||
|
||||
describe('toneOf', () => {
|
||||
it.each([
|
||||
['no snapshot yet', null, 'unknown'],
|
||||
['an open change request', snapshot(1), 'open'],
|
||||
['an open draft', snapshot(1, { draft: true }), 'draft'],
|
||||
['a merged change request', snapshot(1, { state: 'merged', draft: true }), 'merged'],
|
||||
['a closed change request', snapshot(1, { state: 'closed' }), 'closed'],
|
||||
] as const)('draws %s as %s', (_label, value, tone) => {
|
||||
expect(toneOf(value)).toBe(tone)
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkCounts', () => {
|
||||
it('groups neutral, skipped, and cancelled checks as other', () => {
|
||||
expect(checkCounts([])).toEqual({ failed: 0, running: 0, passed: 0, other: 0 })
|
||||
expect(checkCounts([
|
||||
check('lint', { state: 'failure' }),
|
||||
check('test', { state: 'pending' }),
|
||||
check('build'),
|
||||
check('docs', { state: 'neutral' }),
|
||||
check('e2e', { state: 'skipped' }),
|
||||
check('perf', { state: 'cancelled' }),
|
||||
check('types', { state: 'failure' }),
|
||||
])).toEqual({ failed: 2, running: 1, passed: 1, other: 3 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('ciSummary', () => {
|
||||
it.each([
|
||||
['no checks', 'none', { kind: 'none' }],
|
||||
['running checks', 'pending', { kind: 'running' }],
|
||||
['passed checks', 'success', { kind: 'passed' }],
|
||||
] as const)('reports %s from the rollup alone', (_label, checksRollup, summary) => {
|
||||
expect(ciSummary(snapshot(1, { checksRollup, checks: [check('lint', { state: 'failure' })] }))).toEqual(summary)
|
||||
})
|
||||
|
||||
it('counts the listed failures of a failed rollup, which can be none when the list is truncated', () => {
|
||||
const failed = snapshot(1, {
|
||||
checksRollup: 'failure',
|
||||
checks: [check('lint', { state: 'failure' }), check('test'), check('types', { state: 'failure' })],
|
||||
})
|
||||
expect(ciSummary(failed)).toEqual({ kind: 'failed', count: 2 })
|
||||
expect(ciSummary(snapshot(1, { checksRollup: 'failure', checks: [check('test')], checksTruncated: true })))
|
||||
.toEqual({ kind: 'failed', count: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('flagOf', () => {
|
||||
it.each([
|
||||
['requested changes before conflicts and draft state', { reviewDecision: 'changes-requested', mergeability: 'conflicting', draft: true },
|
||||
'changesRequested'],
|
||||
['merge conflicts before draft state', { mergeability: 'conflicting', draft: true }, 'conflicting'],
|
||||
['draft state', { draft: true }, 'draft'],
|
||||
['nothing for a settled open change request', {}, undefined],
|
||||
['nothing for a merged change request', { state: 'merged', reviewDecision: 'changes-requested' }, undefined],
|
||||
['nothing for a closed change request', { state: 'closed', mergeability: 'conflicting' }, undefined],
|
||||
] as const)('names %s', (_label, overrides, flag) => {
|
||||
expect(flagOf(snapshot(1, overrides))).toBe(flag)
|
||||
})
|
||||
})
|
||||
|
||||
describe('needsAttention', () => {
|
||||
it.each([
|
||||
['failed checks', { checksRollup: 'failure' }],
|
||||
['requested changes', { reviewDecision: 'changes-requested' }],
|
||||
['merge conflicts', { mergeability: 'conflicting' }],
|
||||
] as const)('marks an open change request with %s', (_label, overrides) => {
|
||||
expect(needsAttention(entry(1, snapshot(1, overrides)))).toBe(true)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a binding without a snapshot', entry(1, null)],
|
||||
['an unserved URL', unserved('https://example.test/review/1')],
|
||||
['a settled open change request', entry(1, snapshot(1, { checksRollup: 'pending', mergeability: 'unknown', draft: true }))],
|
||||
['a merged change request with failed checks', entry(1, snapshot(1, { state: 'merged', checksRollup: 'failure' }))],
|
||||
['a closed change request with conflicts', entry(1, snapshot(1, { state: 'closed', mergeability: 'conflicting' }))],
|
||||
])('leaves %s unmarked', (_label, value) => {
|
||||
expect(needsAttention(value)).toBe(false)
|
||||
})
|
||||
|
||||
it('reads the kept snapshot of a binding whose latest poll failed', () => {
|
||||
const failed = entry(1, { code: 'unavailable', message: 'down' }, { kept: snapshot(1, { checksRollup: 'failure' }) })
|
||||
expect(needsAttention(failed)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('numberOf', () => {
|
||||
it('prefers the provider reference over the URL', () => {
|
||||
expect(numberOf({ ref: ref(42), url: 'https://github.com/octo/demo/pull/7' })).toBe(42)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a plain path', 'https://example.test/review/17', 17],
|
||||
['a trailing slash', 'https://example.test/review/17/', 17],
|
||||
['a query', 'https://example.test/review/17?tab=files', 17],
|
||||
['a fragment', 'https://example.test/review/17#note', 17],
|
||||
['a trailing slash and a fragment spanning lines', 'https://example.test/review/17/#a\nb', 17],
|
||||
])('reads the number at the end of a URL path with %s', (_label, url, number) => {
|
||||
expect(numberOf({ ref: null, url })).toBe(number)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['no number', 'https://example.test/review/latest'],
|
||||
['digits inside the last segment', 'https://example.test/review/v17'],
|
||||
['a number beyond the safe integer range', 'https://example.test/review/9007199254740993'],
|
||||
])('reads nothing from a URL path with %s', (_label, url) => {
|
||||
expect(numberOf({ ref: null, url })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('checkDuration', () => {
|
||||
it('measures a completed check', () => {
|
||||
expect(checkDuration(check('test'))).toBe(65_000)
|
||||
expect(checkDuration(check('test', { completedAt: '2026-10-09T10:00:00Z' }))).toBe(0)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a check that has not started', { startedAt: null }],
|
||||
['a running check', { completedAt: null }],
|
||||
['an unparseable start time', { startedAt: 'yesterday' }],
|
||||
['a completion before the start', { completedAt: '2026-10-09T09:59:59Z' }],
|
||||
])('measures nothing for %s', (_label, overrides) => {
|
||||
expect(checkDuration(check('test', overrides))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,280 @@
|
||||
// @vitest-environment jsdom
|
||||
/** Worktree card: a running creation, which can be stopped while a message waits for it; the last failed one; and a removed worktree. */
|
||||
import { act, cleanup, fireEvent, render, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { PanelSnapshot } from '../src/client/source.ts'
|
||||
import { WorktreeProgress } from '../src/client/WorktreeProgress.tsx'
|
||||
import css from '../src/client/WorktreeProgress.module.css'
|
||||
import type { ForgePanelState } from '../src/types.ts'
|
||||
import { created, creation, failure, ready, removed, requested, worktree } from './client-fixtures.client.ts'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const t = makeTranslate(en)
|
||||
|
||||
function card(state: ForgePanelState | null) {
|
||||
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: false })
|
||||
const stop = vi.fn<() => void>()
|
||||
const withdraw = vi.fn<() => void>()
|
||||
const restore = vi.fn(async (): Promise<string | undefined> => undefined)
|
||||
const leave = vi.fn<() => void>()
|
||||
const view = render(
|
||||
<WorktreeProgress
|
||||
usePanel={bindSnapshotSelector(panel)}
|
||||
stop={stop}
|
||||
withdraw={withdraw}
|
||||
restore={restore}
|
||||
leave={leave}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
const root = (): HTMLElement | null => view.container.querySelector<HTMLElement>('[data-forge-creation]')
|
||||
const publish = (next: ForgePanelState | null): void => {
|
||||
act(() => { panel.set({ state: next, disconnected: false }) })
|
||||
}
|
||||
return { view, stop, withdraw, restore, leave, root, publish }
|
||||
}
|
||||
|
||||
/** Glyph a step shows for its state. */
|
||||
function glyph(step: Element): string {
|
||||
if (step.querySelector('svg[data-state="ongoing"]') !== null) return 'spinner'
|
||||
if (step.querySelector(`.${css.done}`) !== null) return 'check'
|
||||
if (step.querySelector(`.${css.failed}`) !== null) return 'cross'
|
||||
if (step.querySelector(`.${css.waiting}`) !== null) return 'ring'
|
||||
return 'none'
|
||||
}
|
||||
|
||||
/** Label, state, and glyph of each step, in order. */
|
||||
function steps(view: ReturnType<typeof render>): string[][] {
|
||||
return [...view.container.querySelectorAll('li')].map(step => [step.textContent, step.getAttribute('data-state') ?? '', glyph(step)])
|
||||
}
|
||||
|
||||
/** Term and description pairs of the details, or null while they are hidden. */
|
||||
function details(view: ReturnType<typeof render>): string[][] | null {
|
||||
const list = view.container.querySelector('dl')
|
||||
if (list === null) return null
|
||||
return [...list.querySelectorAll('dt')].map(term => [term.textContent, term.nextElementSibling?.textContent ?? ''])
|
||||
}
|
||||
|
||||
/** Names of the action buttons the card offers, in order. */
|
||||
function actions(view: ReturnType<typeof render>): string[] {
|
||||
const names = [en['creation.skip'], en['creation.cancel']]
|
||||
return view.getAllByRole('button').map(button => button.textContent).filter(name => names.includes(name))
|
||||
}
|
||||
|
||||
/** Whether a button rejects clicks. */
|
||||
function disabled(button: HTMLElement): boolean {
|
||||
return button instanceof HTMLButtonElement && button.disabled
|
||||
}
|
||||
|
||||
describe('WorktreeProgress visibility', () => {
|
||||
it.each([
|
||||
['before the first state', null],
|
||||
['while a request waits for the first message', requested()],
|
||||
['while the Session works in the worktree it created', ready({ directory: '/work/repo-dsh', worktree: worktree({ created: created() }) })],
|
||||
['for a removal the panel reports without the worktree record', ready({ worktree: worktree({ missing: { branchExists: true } }) })],
|
||||
])('renders nothing %s', (_label, state) => {
|
||||
expect(card(state).view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeProgress running creation', () => {
|
||||
it('follows the branch and steps of a creation a message waits for, which the user can stop', () => {
|
||||
const b = card(requested('release', { creation: creation({ from: 'release' }) }))
|
||||
expect(b.root()?.getAttribute('data-forge-creation')).toBe('prepare')
|
||||
const region = b.view.getByRole('region', { name: en['creation.title'] })
|
||||
expect(region.getAttribute('aria-busy')).toBe('true')
|
||||
expect(within(region).getByText(en['creation.title'])).toBeTruthy()
|
||||
expect(within(region).getByText('dsh/0a1b2c3d')).toBeTruthy()
|
||||
expect(steps(b.view)).toEqual([
|
||||
[en['creation.prepare'], 'running', 'spinner'],
|
||||
[en['creation.checkout'], 'waiting', 'ring'],
|
||||
])
|
||||
expect(details(b.view)).toBeNull()
|
||||
expect(actions(b.view)).toEqual([en['creation.cancel']])
|
||||
|
||||
b.publish(requested('release', { creation: creation({ from: 'release', phase: 'checkout' }) }))
|
||||
expect(b.root()?.getAttribute('data-forge-creation')).toBe('checkout')
|
||||
expect(steps(b.view)).toEqual([
|
||||
[en['creation.prepare'], 'done', 'check'],
|
||||
[en['creation.checkout'], 'running', 'spinner'],
|
||||
])
|
||||
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['creation.cancel'] }))
|
||||
expect(b.stop).toHaveBeenCalledOnce()
|
||||
expect(b.withdraw).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows a creation at the first step without actions, because its message already entered the Session', () => {
|
||||
const b = card(requested('main', { creation: creation({ held: false }) }))
|
||||
expect(b.root()?.getAttribute('data-forge-creation')).toBe('prepare')
|
||||
expect(b.view.getByRole('region', { name: en['creation.title'] }).getAttribute('aria-busy')).toBe('true')
|
||||
expect(actions(b.view)).toEqual([])
|
||||
expect(b.view.getByRole('button', { name: en['creation.details'] })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('shows and hides the branch and its base under the disclosure', () => {
|
||||
const b = card(requested('main', { creation: creation() }))
|
||||
const disclosure = b.view.getByRole('button', { name: en['creation.details'] })
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(details(b.view)).toEqual([
|
||||
[en['creation.branch'], 'dsh/0a1b2c3d'],
|
||||
[en['creation.base'], 'main'],
|
||||
])
|
||||
fireEvent.click(disclosure)
|
||||
expect(disclosure.getAttribute('aria-expanded')).toBe('false')
|
||||
expect(details(b.view)).toBeNull()
|
||||
})
|
||||
|
||||
it('names the current HEAD as the base of a creation from it', () => {
|
||||
const b = card(requested(null, { creation: creation({ from: null }) }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['creation.details'] }))
|
||||
expect(details(b.view)).toEqual([
|
||||
[en['creation.branch'], 'dsh/0a1b2c3d'],
|
||||
[en['creation.base'], en['creation.head']],
|
||||
])
|
||||
})
|
||||
|
||||
it('disappears once the creation ends', () => {
|
||||
const b = card(requested('main', { creation: creation() }))
|
||||
b.publish(ready({ directory: '/work/repo-dsh', worktree: worktree({ created: created() }) }))
|
||||
expect(b.view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeProgress failure', () => {
|
||||
it.each([
|
||||
['the preparation', 'prepare' as const, [
|
||||
[en['creation.prepare'], 'failed', 'cross'],
|
||||
[en['creation.checkout'], 'waiting', 'ring'],
|
||||
]],
|
||||
['the checkout', 'checkout' as const, [
|
||||
[en['creation.prepare'], 'done', 'check'],
|
||||
[en['creation.checkout'], 'failed', 'cross'],
|
||||
]],
|
||||
])('marks failed %s and shows the reason and how to retry, without the disclosure', (_label, phase, expected) => {
|
||||
const b = card(requested('main', { failure: failure({ phase }) }))
|
||||
expect(b.root()?.getAttribute('data-forge-creation')).toBe('failed')
|
||||
const region = b.view.getByRole('region', { name: en['creation.failed'] })
|
||||
expect(region.getAttribute('aria-busy')).toBe('false')
|
||||
expect(within(region).getByText(en['creation.failed'])).toBeTruthy()
|
||||
expect(steps(b.view)).toEqual(expected)
|
||||
expect(details(b.view)).toEqual([
|
||||
[en['creation.branch'], 'dsh/0a1b2c3d'],
|
||||
[en['creation.base'], 'main'],
|
||||
[en['creation.error'], 'fatal: invalid reference: main'],
|
||||
])
|
||||
expect(within(region).getByText(en['creation.retryHint'])).toBeTruthy()
|
||||
expect(b.view.queryByRole('button', { name: en['creation.details'] })).toBeNull()
|
||||
expect(actions(b.view)).toEqual([en['creation.skip']])
|
||||
})
|
||||
|
||||
it('names no branch for a failure that came before one, and the current HEAD as its base', () => {
|
||||
const b = card(requested(null, { failure: failure({ branch: null, from: null, message: 'connection lost' }) }))
|
||||
const region = b.view.getByRole('region', { name: en['creation.failed'] })
|
||||
expect(within(region).queryByText('dsh/0a1b2c3d')).toBeNull()
|
||||
expect(details(b.view)).toEqual([
|
||||
[en['creation.base'], en['creation.head']],
|
||||
[en['creation.error'], 'connection lost'],
|
||||
])
|
||||
})
|
||||
|
||||
it('withdraws the request so the message can go without a worktree', () => {
|
||||
const b = card(requested('main', { failure: failure() }))
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['creation.skip'] }))
|
||||
expect(b.withdraw).toHaveBeenCalledOnce()
|
||||
expect(b.stop).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('collapses the details again when another attempt starts', () => {
|
||||
const b = card(requested('main', { failure: failure() }))
|
||||
expect(details(b.view)).not.toBeNull()
|
||||
b.publish(requested('main', { creation: creation() }))
|
||||
expect(b.root()?.getAttribute('data-forge-creation')).toBe('prepare')
|
||||
expect(details(b.view)).toBeNull()
|
||||
expect(b.view.getByRole('button', { name: en['creation.details'] }).getAttribute('aria-expanded')).toBe('false')
|
||||
expect(actions(b.view)).toEqual([en['creation.cancel']])
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeProgress removed worktree', () => {
|
||||
it.each([
|
||||
['still exists', true, t('missing.kept', { branch: 'dsh/0a1b2c3d' })],
|
||||
['was removed too', false, t('missing.recreated', { branch: 'dsh/0a1b2c3d' })],
|
||||
])('names the worktree and explains restoring it when its branch %s', (_label, branchExists, note) => {
|
||||
const b = card(removed(branchExists))
|
||||
expect(b.view.container.querySelector('[data-forge-missing]')).not.toBeNull()
|
||||
expect(b.root()).toBeNull()
|
||||
const region = b.view.getByRole('region', { name: en['missing.title'] })
|
||||
expect(within(region).getByText(en['missing.title'])).toBeTruthy()
|
||||
expect(within(region).getByText('dsh/0a1b2c3d')).toBeTruthy()
|
||||
expect(within(region).getByText(note)).toBeTruthy()
|
||||
expect(region.getAttribute('aria-busy')).toBeNull()
|
||||
})
|
||||
|
||||
it('restores the worktree and stays busy until the Host answers', async () => {
|
||||
const b = card(removed())
|
||||
const answer = Promise.withResolvers<string | undefined>()
|
||||
b.restore.mockReturnValueOnce(answer.promise)
|
||||
const restore = b.view.getByRole('button', { name: en['missing.restore'] })
|
||||
const leave = b.view.getByRole('button', { name: en['missing.leave'] })
|
||||
|
||||
fireEvent.click(restore)
|
||||
expect(b.restore).toHaveBeenCalledOnce()
|
||||
expect([disabled(restore), disabled(leave)]).toEqual([true, true])
|
||||
|
||||
await act(async () => { answer.resolve(undefined) })
|
||||
expect([disabled(restore), disabled(leave)]).toEqual([false, false])
|
||||
expect(b.view.container.querySelector(`.${css.error}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('shows why a restore failed until the next attempt starts', async () => {
|
||||
const b = card(removed())
|
||||
const reason = 'fatal: \'/work/repo-dsh\' already exists'
|
||||
b.restore.mockResolvedValueOnce(reason)
|
||||
const restore = b.view.getByRole('button', { name: en['missing.restore'] })
|
||||
await act(async () => { fireEvent.click(restore) })
|
||||
expect(b.view.container.querySelector(`.${css.error}`)?.textContent).toBe(reason)
|
||||
expect(disabled(restore)).toBe(false)
|
||||
|
||||
const retry = Promise.withResolvers<string | undefined>()
|
||||
b.restore.mockReturnValueOnce(retry.promise)
|
||||
fireEvent.click(restore)
|
||||
expect(b.view.container.querySelector(`.${css.error}`)).toBeNull()
|
||||
await act(async () => { retry.resolve(undefined) })
|
||||
expect(b.restore).toHaveBeenCalledTimes(2)
|
||||
expect(b.view.container.querySelector(`.${css.error}`)).toBeNull()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['an error', new Error('connection lost'), 'connection lost'],
|
||||
['another value', 'offline', 'offline'],
|
||||
])('shows the reason when restoring throws %s and offers both actions again', async (_label, thrown, reason) => {
|
||||
const b = card(removed())
|
||||
b.restore.mockRejectedValueOnce(thrown)
|
||||
const restore = b.view.getByRole('button', { name: en['missing.restore'] })
|
||||
const leave = b.view.getByRole('button', { name: en['missing.leave'] })
|
||||
await act(async () => { fireEvent.click(restore) })
|
||||
expect(b.restore).toHaveBeenCalledOnce()
|
||||
expect(b.view.container.querySelector(`.${css.error}`)?.textContent).toBe(reason)
|
||||
expect([disabled(restore), disabled(leave)]).toEqual([false, false])
|
||||
})
|
||||
|
||||
it('continues in the project', () => {
|
||||
const b = card(removed())
|
||||
fireEvent.click(b.view.getByRole('button', { name: en['missing.leave'] }))
|
||||
expect(b.leave).toHaveBeenCalledOnce()
|
||||
expect(b.restore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disappears once the worktree is back', () => {
|
||||
const b = card(removed())
|
||||
b.publish(ready({ directory: '/work/repo-dsh', worktree: worktree({ created: created() }) }, { root: '/work/repo-dsh', branch: 'dsh/0a1b2c3d' }))
|
||||
expect(b.view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,298 @@
|
||||
// @vitest-environment jsdom
|
||||
/** New Session worktree option: when it shows, why it is disabled, the optimistic checkbox, the base menu, and a running creation. */
|
||||
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
|
||||
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import menuCss from '@deepseek-ai/dsh-client-ui-primitives/src/Menu.module.css'
|
||||
import { en } from '../src/client/locales.ts'
|
||||
import type { PanelSnapshot } from '../src/client/source.ts'
|
||||
import { WorktreeToggle } from '../src/client/WorktreeToggle.tsx'
|
||||
import type { ForgeBranches, ForgeCheckoutState, ForgePanelState, ForgePanelWorktree, ForgeWorktreeBlocker } from '../src/types.ts'
|
||||
import { creation, failure, ready, worktree } from './client-fixtures.client.ts'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const t = makeTranslate(en)
|
||||
const BRANCHES: ForgeBranches = { current: 'main', branches: ['main', 'release', 'feat/x'] }
|
||||
|
||||
/** Panel state of a Session that can still start in a worktree. */
|
||||
function editable(overrides: Partial<ForgePanelWorktree> = {}, checkout?: ForgeCheckoutState): ForgePanelState {
|
||||
const state = ready({ worktree: worktree({ editable: true, ...overrides }) })
|
||||
return checkout === undefined ? state : { ...state, checkout }
|
||||
}
|
||||
|
||||
/** A promise the test settles by hand. */
|
||||
function deferred<T>() {
|
||||
let resolve: (value: T) => void = () => {}
|
||||
const promise = new Promise<T>((settle) => { resolve = settle })
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function option(state: ForgePanelState | null) {
|
||||
const panel = createSnapshotStore<PanelSnapshot>({ state, disconnected: false })
|
||||
const request = vi.fn(async (_from: string | null) => true)
|
||||
const cancel = vi.fn(async () => true)
|
||||
const branches = vi.fn(async (): Promise<ForgeBranches | undefined> => BRANCHES)
|
||||
const view = render(
|
||||
<WorktreeToggle
|
||||
usePanel={bindSnapshotSelector(panel)}
|
||||
request={request}
|
||||
cancel={cancel}
|
||||
branches={branches}
|
||||
t={t}
|
||||
/>,
|
||||
)
|
||||
const checkbox = (): HTMLInputElement => {
|
||||
const input = view.getByRole('checkbox', { name: en['worktree.label'] })
|
||||
if (!(input instanceof HTMLInputElement)) throw new Error('no checkbox')
|
||||
return input
|
||||
}
|
||||
const base = (): HTMLButtonElement | null => {
|
||||
const button = view.container.querySelector('button[aria-haspopup="menu"]')
|
||||
return button instanceof HTMLButtonElement ? button : null
|
||||
}
|
||||
const publish = (next: ForgePanelState): void => {
|
||||
act(() => { panel.set({ state: next, disconnected: false }) })
|
||||
}
|
||||
return { view, panel, request, cancel, branches, checkbox, base, publish }
|
||||
}
|
||||
|
||||
/** Open the base branch menu and wait for its branches. */
|
||||
async function openMenu(b: ReturnType<typeof option>): Promise<HTMLElement> {
|
||||
await act(async () => { fireEvent.click(b.base()!) })
|
||||
return screen.getByRole('menu')
|
||||
}
|
||||
|
||||
/** The tooltip of the option after the pointer rests on it. */
|
||||
function hint(b: ReturnType<typeof option>): string | null {
|
||||
vi.useFakeTimers()
|
||||
const label = b.checkbox().closest('label')
|
||||
if (label === null) throw new Error('no option label')
|
||||
fireEvent.mouseEnter(label)
|
||||
act(() => { vi.advanceTimersByTime(500) })
|
||||
return screen.getByRole('tooltip').textContent
|
||||
}
|
||||
|
||||
describe('WorktreeToggle visibility', () => {
|
||||
it.each([
|
||||
['before the first state', null],
|
||||
['once the Session can no longer change its worktree', ready()],
|
||||
['while the first inspection runs', editable({}, { state: 'pending' })],
|
||||
['when the inspection failed', editable({}, { state: 'failed', message: 'git status failed' })],
|
||||
])('renders nothing %s', (_label, state) => {
|
||||
expect(option(state).view.container.innerHTML).toBe('')
|
||||
})
|
||||
|
||||
it('appears and disappears with the editable state', () => {
|
||||
const b = option(ready())
|
||||
b.publish(editable())
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
b.publish(ready({ worktree: worktree({ editable: false, request: { from: 'main' } }) }))
|
||||
expect(b.view.container.innerHTML).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeToggle checkbox', () => {
|
||||
it('disables the option outside a Git checkout and says why', () => {
|
||||
const b = option(editable({ blocker: 'not-git' }, { state: 'none' }))
|
||||
expect(b.checkbox().disabled).toBe(true)
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
|
||||
expect(b.base()).toBeNull()
|
||||
expect(hint(b)).toBe(en['worktree.blocked.not-git'])
|
||||
})
|
||||
|
||||
it.each<[ForgeWorktreeBlocker, string]>([
|
||||
['read-only', en['worktree.blocked.read-only']],
|
||||
['outside-writable-root', en['worktree.blocked.outside-writable-root']],
|
||||
])('disables the option of a Git checkout blocked as %s and says why', (blocker, reason) => {
|
||||
const b = option(editable({ blocker }))
|
||||
expect(b.checkbox().disabled).toBe(true)
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
|
||||
expect(b.base()).toBeNull()
|
||||
expect(hint(b)).toBe(reason)
|
||||
})
|
||||
|
||||
it('keeps an open request of a blocked checkout checked, without its base menu, until it is withdrawn', async () => {
|
||||
const b = option(editable({ blocker: 'read-only', request: { from: 'main' } }))
|
||||
expect(b.checkbox().checked).toBe(true)
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
|
||||
expect(b.base()).toBeNull()
|
||||
expect(hint(b)).toBe(en['worktree.blocked.read-only'])
|
||||
vi.useRealTimers()
|
||||
await act(async () => { fireEvent.click(b.checkbox()) })
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.request).not.toHaveBeenCalled()
|
||||
b.publish(editable({ blocker: 'read-only' }))
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('offers the option again once the blocker clears', () => {
|
||||
const b = option(editable({ blocker: 'read-only' }))
|
||||
b.publish(editable())
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
|
||||
expect(hint(b)).toBe(en['worktree.hint'])
|
||||
})
|
||||
|
||||
it('offers the option unchecked inside a Git checkout', () => {
|
||||
const b = option(editable())
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
|
||||
expect(b.base()).toBeNull()
|
||||
expect(hint(b)).toBe(en['worktree.hint'])
|
||||
})
|
||||
|
||||
it('requests a worktree from the current branch and holds the checked state until the Host answers', async () => {
|
||||
const b = option(editable())
|
||||
const answer = deferred<boolean>()
|
||||
b.request.mockReturnValueOnce(answer.promise)
|
||||
|
||||
fireEvent.click(b.checkbox())
|
||||
expect(b.request).toHaveBeenCalledWith('main')
|
||||
expect(b.checkbox().checked).toBe(true)
|
||||
expect(b.checkbox().disabled).toBe(true)
|
||||
expect(b.base()?.textContent).toBe('From main')
|
||||
|
||||
b.publish(editable({ request: { from: 'main' } }))
|
||||
await act(async () => { answer.resolve(true) })
|
||||
expect(b.checkbox().checked).toBe(true)
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
expect(b.base()?.textContent).toBe('From main')
|
||||
})
|
||||
|
||||
it('requests a worktree from HEAD on a detached checkout', async () => {
|
||||
const b = option(ready({ worktree: worktree({ editable: true }) }, { branch: null }))
|
||||
await act(async () => { fireEvent.click(b.checkbox()) })
|
||||
expect(b.request).toHaveBeenCalledWith(null)
|
||||
b.publish(ready({ worktree: worktree({ editable: true, request: { from: null } }) }, { branch: null }))
|
||||
expect(b.base()?.textContent).toBe(en['worktree.baseHead'])
|
||||
})
|
||||
|
||||
it('returns to the Host state when the Host refuses the request', async () => {
|
||||
const b = option(editable())
|
||||
b.request.mockResolvedValueOnce(false)
|
||||
await act(async () => { fireEvent.click(b.checkbox()) })
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
expect(b.base()).toBeNull()
|
||||
})
|
||||
|
||||
it('withdraws an open request and hides the base branch until the Host answers', async () => {
|
||||
const b = option(editable({ request: { from: 'release' } }))
|
||||
expect(b.checkbox().checked).toBe(true)
|
||||
expect(b.base()?.textContent).toBe('From release')
|
||||
const answer = deferred<boolean>()
|
||||
b.cancel.mockReturnValueOnce(answer.promise)
|
||||
|
||||
fireEvent.click(b.checkbox())
|
||||
expect(b.cancel).toHaveBeenCalledOnce()
|
||||
expect(b.request).not.toHaveBeenCalled()
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().disabled).toBe(true)
|
||||
expect(b.base()).toBeNull()
|
||||
|
||||
b.publish(editable())
|
||||
await act(async () => { answer.resolve(true) })
|
||||
expect(b.checkbox().checked).toBe(false)
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeToggle base branch menu', () => {
|
||||
it('names the current branch for a request from HEAD', () => {
|
||||
const b = option(editable({ request: { from: null } }))
|
||||
expect(b.base()?.textContent).toBe('From main')
|
||||
})
|
||||
|
||||
it('sits before the checkbox and opens below its anchor, aligned to the anchor\'s right edge', async () => {
|
||||
const b = option(editable({ request: { from: 'main' } }))
|
||||
const anchor = b.base()
|
||||
if (anchor === null) throw new Error('no base branch anchor')
|
||||
expect(anchor.compareDocumentPosition(b.checkbox()) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||
const wrapper = anchor.parentElement
|
||||
if (wrapper === null) throw new Error('no menu wrapper')
|
||||
vi.spyOn(wrapper, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 40, 120, 28))
|
||||
|
||||
const menu = await openMenu(b)
|
||||
expect(menu.style.left).toBe('220px')
|
||||
expect(menu.style.top).toBe('72px')
|
||||
})
|
||||
|
||||
it('lists the local branches, marks the current and the chosen one, and requests another base', async () => {
|
||||
const b = option(editable({ request: { from: 'main' } }))
|
||||
const answer = deferred<ForgeBranches | undefined>()
|
||||
b.branches.mockReturnValueOnce(answer.promise)
|
||||
fireEvent.click(b.base()!)
|
||||
expect(b.branches).toHaveBeenCalledOnce()
|
||||
expect(b.base()?.disabled).toBe(true)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
|
||||
await act(async () => { answer.resolve(BRANCHES) })
|
||||
expect(b.base()?.disabled).toBe(false)
|
||||
expect(b.base()?.getAttribute('aria-expanded')).toBe('true')
|
||||
const menu = screen.getByRole('menu')
|
||||
expect(within(menu).getByText(en['worktree.baseMenu'])).toBeTruthy()
|
||||
const items = within(menu).getAllByRole('menuitem')
|
||||
expect(items.map(item => item.textContent)).toEqual([`main${en['worktree.currentBranch']}`, 'release', 'feat/x'])
|
||||
expect(items.map(item => item.matches(`.${menuCss.selected}`))).toEqual([true, false, false])
|
||||
|
||||
fireEvent.click(items[1]!)
|
||||
expect(b.request).toHaveBeenCalledWith('release')
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes without a request when the chosen base is selected again', async () => {
|
||||
const b = option(editable({ request: { from: 'release' } }))
|
||||
const menu = await openMenu(b)
|
||||
fireEvent.click(within(menu).getByRole('menuitem', { name: 'release' }))
|
||||
expect(b.request).not.toHaveBeenCalled()
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes from its anchor and on Escape', async () => {
|
||||
const b = option(editable({ request: { from: 'main' } }))
|
||||
await openMenu(b)
|
||||
fireEvent.click(b.base()!)
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.branches).toHaveBeenCalledOnce()
|
||||
|
||||
await openMenu(b)
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.request).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stays closed when the branches cannot be read', async () => {
|
||||
const b = option(editable({ request: { from: 'main' } }))
|
||||
b.branches.mockResolvedValueOnce(undefined)
|
||||
await act(async () => { fireEvent.click(b.base()!) })
|
||||
expect(screen.queryByRole('menu')).toBeNull()
|
||||
expect(b.base()?.disabled).toBe(false)
|
||||
expect(b.base()?.getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeToggle while the worktree is created', () => {
|
||||
it('freezes the checkbox and the base branch until the creation fails, which leaves both open to change', () => {
|
||||
const b = option(editable({ request: { from: 'main' }, creation: creation() }))
|
||||
expect(b.checkbox().checked).toBe(true)
|
||||
expect(b.checkbox().disabled).toBe(true)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(true)
|
||||
expect(b.base()?.disabled).toBe(true)
|
||||
|
||||
b.publish(editable({ request: { from: 'main' }, failure: failure() }))
|
||||
expect(b.checkbox().checked).toBe(true)
|
||||
expect(b.checkbox().disabled).toBe(false)
|
||||
expect(b.checkbox().closest('label')?.hasAttribute('data-disabled')).toBe(false)
|
||||
expect(b.base()?.disabled).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,501 @@
|
||||
import { access } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import type { AgentCancelCause } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-experimental-forge-session'
|
||||
import SessionStore, { appendPluginRecord, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
|
||||
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
|
||||
import type {} from '@deepseek-ai/dsh-working-directory'
|
||||
import type { ForgeSessionWorktree, ForgeWorktreeCreation, ForgeWorktreeState } from '../src/index.ts'
|
||||
import { CreationFailure, forgeWorktreeProjection, WORKTREE_RECORD, WorktreeCreations } from '../src/worktree.ts'
|
||||
import {
|
||||
branches, commitAt, git, hold, notice, prompt, proposeStep, rejectionOf, repository, runtime, scratch, startStep, startTurn,
|
||||
} from './harness.ts'
|
||||
import type { Runtime } from './harness.ts'
|
||||
|
||||
const cleanups: (() => Promise<void>)[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
|
||||
})
|
||||
|
||||
const signal = new AbortController().signal
|
||||
|
||||
const WORKTREE: ForgeSessionWorktree = {
|
||||
path: '/work/repo/.agents/worktrees/dsh/0a1b2c3d', branch: 'dsh/0a1b2c3d', from: null, baseCommit: 'c'.repeat(40), repositoryRoot: '/work/repo',
|
||||
}
|
||||
|
||||
/** The state before any worktree record, turn, or step. */
|
||||
const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false }
|
||||
|
||||
/** A live Session whose `forgeWorktree` state a registry folds without the panel Remote. */
|
||||
async function folded(): Promise<{ session: Session; state: () => ForgeWorktreeState }> {
|
||||
const ctx = new Context()
|
||||
cleanups.push(() => ctx.fiber.dispose())
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionProjectionRegistry)
|
||||
const states = await ctx.sessionProjections.register(forgeWorktreeProjection)
|
||||
const session = ctx.sessions.create(SessionId('forge-worktree-projection'))
|
||||
return { session, state: () => states.stateOf(session) }
|
||||
}
|
||||
|
||||
describe('forgeWorktree projection', () => {
|
||||
it('starts without a request, a created worktree, a started turn, or a started step', async () => {
|
||||
const { state } = await folded()
|
||||
|
||||
expect(state()).toEqual(EMPTY)
|
||||
})
|
||||
|
||||
it('opens, replaces, and withdraws the request until the first turn starts', async () => {
|
||||
const { session, state } = await folded()
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null })
|
||||
expect(state()?.request).toEqual({ from: null })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: 'main' })
|
||||
expect(state()?.request).toEqual({ from: 'main' })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'cancel' })
|
||||
const withdrawn = state()
|
||||
expect(withdrawn).toEqual(EMPTY)
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'cancel' })
|
||||
expect(state()).toBe(withdrawn)
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: 'feat' })
|
||||
startTurn(session, 1)
|
||||
const locked = state()
|
||||
expect(locked).toEqual({ ...EMPTY, request: { from: 'feat' }, locked: true })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: 'main' })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'cancel' })
|
||||
expect(state()).toBe(locked)
|
||||
})
|
||||
|
||||
it('applies the first created worktree of an open request, after which requests, withdrawals, and creations change nothing', async () => {
|
||||
const { session, state } = await folded()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: 'main' })
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: { ...WORKTREE, from: 'main' } })
|
||||
const created = state()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: { ...WORKTREE, branch: 'dsh/ffffffff' } })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'cancel' })
|
||||
|
||||
expect(created).toEqual({ ...EMPTY, request: { from: 'main' }, created: { ...WORKTREE, from: 'main' } })
|
||||
expect(state()).toBe(created)
|
||||
})
|
||||
|
||||
it('applies a created worktree after the first turn starts, until the first step starts', async () => {
|
||||
const { session, state } = await folded()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null })
|
||||
startTurn(session, 1)
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE })
|
||||
const created = state()
|
||||
startStep(session, 1)
|
||||
|
||||
expect(created).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: false })
|
||||
expect(state()).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: true })
|
||||
})
|
||||
|
||||
it('ignores a created worktree without an open request or after the first step starts', async () => {
|
||||
const { session, state } = await folded()
|
||||
const initial = state()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE })
|
||||
expect(state()).toBe(initial)
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'cancel' })
|
||||
const withdrawn = state()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE })
|
||||
expect(state()).toBe(withdrawn)
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: null })
|
||||
startTurn(session, 1)
|
||||
startStep(session, 1)
|
||||
const stepped = state()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE })
|
||||
|
||||
expect(stepped).toEqual({ request: { from: null }, created: null, locked: true, stepped: true })
|
||||
expect(state()).toBe(stepped)
|
||||
})
|
||||
|
||||
it('counts only the first turn start and the first step start', async () => {
|
||||
const { session, state } = await folded()
|
||||
startTurn(session, 1)
|
||||
const locked = state()
|
||||
startTurn(session, 2)
|
||||
expect(state()).toBe(locked)
|
||||
|
||||
startStep(session, 2)
|
||||
const stepped = state()
|
||||
startStep(session, 2, 2)
|
||||
startTurn(session, 3)
|
||||
|
||||
expect(locked).toEqual({ ...EMPTY, locked: true })
|
||||
expect(stepped).toEqual({ ...EMPTY, locked: true, stepped: true })
|
||||
expect(state()).toBe(stepped)
|
||||
})
|
||||
|
||||
it('ignores malformed worktree records, other plugin records, and other events', async () => {
|
||||
const { session, state } = await folded()
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: 'main' })
|
||||
const requested = state()
|
||||
const cancelWithReason = { action: 'cancel' as const, reason: 'stale' }
|
||||
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: '' })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, cancelWithReason)
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: { ...WORKTREE, path: '' } })
|
||||
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: { ...WORKTREE, repositoryRoot: '' } })
|
||||
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'track', url: 'https://fake.test/octo/demo/pull/1' })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
|
||||
expect(state()).toBe(requested)
|
||||
expect(requested).toEqual({ ...EMPTY, request: { from: 'main' } })
|
||||
})
|
||||
})
|
||||
|
||||
async function project(): Promise<string> {
|
||||
const { path, remove } = await scratch('dsh-forge-worktree-')
|
||||
cleanups.push(remove)
|
||||
return await repository(path)
|
||||
}
|
||||
|
||||
/** A {@link Runtime} with the `forgeWorktree` unit and its own {@link WorktreeCreations} in place of the panel Remote. */
|
||||
interface Creating extends Runtime {
|
||||
readonly creations: WorktreeCreations
|
||||
/** Reads the `forgeWorktree` projection the creations read. */
|
||||
readonly states: ProjectionHandle<ForgeWorktreeState>
|
||||
/** The Session's running creation at each `changed` call, in call order. */
|
||||
readonly changes: (ForgeWorktreeCreation | null)[]
|
||||
/** The Session's created worktree at each `changed` call, in call order. */
|
||||
readonly recorded: (ForgeSessionWorktree | null)[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a {@link runtime} whose worktree creations report every change.
|
||||
* @param cwd - Session project directory.
|
||||
* @param options - branch prefix, `dsh/` when omitted, and whether the worktree service is mounted, as it is when omitted.
|
||||
* @returns the composition.
|
||||
*/
|
||||
async function creating(cwd: string, options: { readonly prefix?: string; readonly worktrees?: boolean } = {}): Promise<Creating> {
|
||||
const base = await runtime(cwd, { worktrees: options.worktrees ?? true })
|
||||
cleanups.push(() => base.ctx.fiber.dispose())
|
||||
const states = await base.ctx.sessionProjections.register(forgeWorktreeProjection)
|
||||
const changes: (ForgeWorktreeCreation | null)[] = []
|
||||
const recorded: (ForgeSessionWorktree | null)[] = []
|
||||
const creations: WorktreeCreations = new WorktreeCreations(base.ctx, states, options.prefix ?? 'dsh/', (session) => {
|
||||
changes.push(creations.of(session))
|
||||
recorded.push(states.stateOf(session).created)
|
||||
})
|
||||
return { ...base, creations, states, changes, recorded }
|
||||
}
|
||||
|
||||
/** Matches a generated branch name with the default prefix. */
|
||||
const GENERATED_BRANCH: unknown = expect.stringMatching(/^dsh\/[0-9a-f]{8}$/)
|
||||
|
||||
/** Matches the failure of an attempt whose base branch does not exist. */
|
||||
const MISSING_BASE: unknown = expect.stringMatching(/^worktree command failed \(128\): fatal: /)
|
||||
|
||||
function request(opened: Runtime, from: string | null): void {
|
||||
appendPluginRecord(opened.session, WORKTREE_RECORD, { action: 'request', from })
|
||||
}
|
||||
|
||||
function stateOf(opened: Creating): ForgeWorktreeState {
|
||||
return opened.states.stateOf(opened.session)
|
||||
}
|
||||
|
||||
function worktreeOf(opened: Creating): ForgeSessionWorktree | null {
|
||||
return stateOf(opened).created
|
||||
}
|
||||
|
||||
describe('creating a requested worktree', () => {
|
||||
it('creates a branch and checkout from HEAD with a new name, enters it, and records it with the source repository root', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
const head = await git(repo, 'rev-parse', 'HEAD')
|
||||
request(opened, null)
|
||||
|
||||
const result = await opened.creations.create(opened.agent, null, signal, false)
|
||||
|
||||
const branch = result.branch
|
||||
expect(branch).toMatch(/^dsh\/[0-9a-f]{8}$/)
|
||||
const path = join(repo, '.agents', 'worktrees', ...branch.split('/'))
|
||||
const created = { path, branch, from: null, baseCommit: head, repositoryRoot: repo }
|
||||
expect(result).toEqual({ path, branch, baseCommit: head, repositoryRoot: repo })
|
||||
expect(worktreeOf(opened)).toEqual(created)
|
||||
expect(opened.records).toEqual([{ action: 'request', from: null }, { action: 'created', worktree: created }])
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(path)
|
||||
expect(await git(path, 'symbolic-ref', '--short', 'HEAD')).toBe(branch)
|
||||
expect(await git(path, 'rev-parse', 'HEAD')).toBe(head)
|
||||
expect(opened.agent.inbox.nextStep).toEqual([notice(path)])
|
||||
})
|
||||
|
||||
it('reports the attempt when it starts and before each step, and ends it only after recording the worktree', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
|
||||
const result = await opened.creations.create(opened.agent, null, signal, false)
|
||||
|
||||
const running = { branch: result.branch, from: null, held: false }
|
||||
expect(opened.changes).toEqual([{ ...running, phase: 'prepare' }, { ...running, phase: 'prepare' }, { ...running, phase: 'checkout' }, null])
|
||||
expect(opened.recorded).toEqual([null, null, null, worktreeOf(opened)])
|
||||
expect(worktreeOf(opened)).not.toBeNull()
|
||||
expect(opened.creations.of(opened.session)).toBeNull()
|
||||
})
|
||||
|
||||
it('starts the branch from the given base branch', async () => {
|
||||
const repo = await project()
|
||||
await git(repo, 'checkout', '-b', 'base')
|
||||
const base = await commitAt(repo, 'base.txt', '2026-01-01T00:00:00Z')
|
||||
await git(repo, 'checkout', 'main')
|
||||
const opened = await creating(repo)
|
||||
request(opened, 'base')
|
||||
|
||||
await opened.creations.create(opened.agent, 'base', signal, false)
|
||||
|
||||
const created = worktreeOf(opened)
|
||||
expect(created).toMatchObject({ from: 'base', baseCommit: base })
|
||||
expect(opened.changes[0]).toMatchObject({ from: 'base' })
|
||||
await expect(access(join(created?.path ?? repo, 'base.txt'))).resolves.toBeUndefined()
|
||||
expect(await git(repo, 'rev-parse', 'HEAD')).not.toBe(base)
|
||||
})
|
||||
|
||||
it('names the branch and checkout with the given prefix', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo, { prefix: 'agent/task-' })
|
||||
request(opened, null)
|
||||
|
||||
const result = await opened.creations.create(opened.agent, null, signal, false)
|
||||
|
||||
expect(result.branch).toMatch(/^agent\/task-[0-9a-f]{8}$/)
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(join(repo, '.agents', 'worktrees', ...result.branch.split('/')))
|
||||
})
|
||||
|
||||
it('fails with the branch and step of a failed attempt, recording nothing and keeping the request open', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, 'later')
|
||||
|
||||
const attempt = opened.creations.create(opened.agent, 'later', signal, false)
|
||||
const branch = opened.changes[0]?.branch
|
||||
const cause: unknown = expect.objectContaining({ message: MISSING_BASE })
|
||||
|
||||
await expect(attempt).rejects.toBeInstanceOf(CreationFailure)
|
||||
await expect(attempt).rejects.toMatchObject({
|
||||
name: 'CreationFailure',
|
||||
message: `The requested worktree ${branch} could not be created`,
|
||||
branch,
|
||||
phase: 'prepare',
|
||||
reason: MISSING_BASE,
|
||||
cause,
|
||||
})
|
||||
expect(branch).toEqual(GENERATED_BRANCH)
|
||||
const running = { phase: 'prepare', branch, from: 'later', held: false }
|
||||
expect(opened.changes).toEqual([running, running, null])
|
||||
expect(opened.records).toEqual([{ action: 'request', from: 'later' }])
|
||||
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: 'later' } })
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
})
|
||||
|
||||
it('names the checkout step when writing the files fails, retaining the new branch', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
opened.sandbox.gate = argv => argv.includes('reset') ? Promise.reject(new Error('checkout refused')) : undefined
|
||||
|
||||
const attempt = opened.creations.create(opened.agent, null, signal, false)
|
||||
const branch = opened.changes[0]?.branch ?? ''
|
||||
|
||||
await expect(attempt).rejects.toMatchObject({
|
||||
branch,
|
||||
phase: 'checkout',
|
||||
reason: `Could not finish worktree ${branch} at ${join(repo, '.agents', 'worktrees', ...branch.split('/'))}. `
|
||||
+ 'Any created checkout and branch are retained. checkout refused',
|
||||
})
|
||||
expect(opened.changes.map(change => change?.phase ?? null)).toEqual(['prepare', 'prepare', 'checkout', null])
|
||||
expect(opened.records).toEqual([{ action: 'request', from: null }])
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
expect(await branches(repo)).toEqual([branch, 'main'])
|
||||
})
|
||||
|
||||
it('reports a failure that is not an Error by its text', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
opened.sandbox.failure = 'confinement denied'
|
||||
|
||||
await expect(opened.creations.create(opened.agent, null, signal, false))
|
||||
.rejects.toMatchObject({ phase: 'prepare', reason: 'confinement denied', cause: 'confinement denied' })
|
||||
expect(opened.changes.at(-1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('worktree creation at the first step proposal', () => {
|
||||
it('creates the requested worktree, enters it, and adds the directory notice to the proposed messages', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
const head = await git(repo, 'rev-parse', 'HEAD')
|
||||
const queued = prompt('queued steering')
|
||||
opened.agent.inbox.append('next-step', queued)
|
||||
request(opened, null)
|
||||
startTurn(opened.session, 1)
|
||||
const proposed = prompt('Fix the bug')
|
||||
|
||||
const decision = await proposeStep(opened.ctx, opened.agent, {
|
||||
turn: 1, step: 1, messages: [proposed], decision: { kind: 'enter', messages: [proposed], startsRequestSeries: true },
|
||||
})
|
||||
|
||||
const created = worktreeOf(opened)
|
||||
const branch = created?.branch ?? ''
|
||||
expect(branch).toMatch(/^dsh\/[0-9a-f]{8}$/)
|
||||
const path = join(repo, '.agents', 'worktrees', ...branch.split('/'))
|
||||
expect(created).toEqual({ path, branch, from: null, baseCommit: head, repositoryRoot: repo })
|
||||
expect(opened.records).toEqual([{ action: 'request', from: null }, { action: 'created', worktree: created }])
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(path)
|
||||
expect(await git(path, 'symbolic-ref', '--short', 'HEAD')).toBe(branch)
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [proposed, notice(path)], startsRequestSeries: true })
|
||||
expect(opened.agent.inbox.nextStep).toEqual([queued])
|
||||
expect(opened.changes[0]).toMatchObject({ held: false })
|
||||
expect(opened.changes.at(-1)).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves the directory notice queued for a proposal without messages', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
|
||||
const decision = await proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1 })
|
||||
|
||||
const path = opened.ctx.workingDirectory.get(opened.session)
|
||||
expect(path).toBe(worktreeOf(opened)?.path)
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [] })
|
||||
expect(opened.agent.inbox.nextStep).toEqual([notice(path)])
|
||||
})
|
||||
|
||||
it('leaves the directory notice queued when a later listener rejects the step, and creates no second worktree', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
const proposed = prompt('go')
|
||||
|
||||
const rejected = await proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed], decision: { kind: 'reject' } })
|
||||
const path = opened.ctx.workingDirectory.get(opened.session)
|
||||
const entered = await proposeStep(opened.ctx, opened.agent, { turn: 2, step: 1, messages: [proposed] })
|
||||
|
||||
expect(rejected).toEqual({ kind: 'reject' })
|
||||
expect(path).toBe(worktreeOf(opened)?.path)
|
||||
expect(entered).toEqual({ kind: 'enter', messages: [proposed] })
|
||||
expect(opened.agent.inbox.nextStep).toEqual([notice(path)])
|
||||
expect(opened.records).toHaveLength(2)
|
||||
expect(await branches(repo)).toEqual([worktreeOf(opened)?.branch, 'main'])
|
||||
})
|
||||
|
||||
it('enters the worktree and keeps the proposal when the directory notice cannot be queued', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
vi.spyOn(opened.agent, 'inject').mockImplementation(() => { throw new Error('the inbox is closed') })
|
||||
request(opened, null)
|
||||
const proposed = prompt('go')
|
||||
|
||||
const decision = await proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed] })
|
||||
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(worktreeOf(opened)?.path)
|
||||
expect(opened.agent.inbox.nextStep).toEqual([])
|
||||
})
|
||||
|
||||
it('passes a proposal through without a request, at a later step, and after a step started', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
const proposed = prompt('go')
|
||||
const entered = { kind: 'enter', messages: [proposed] }
|
||||
|
||||
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed] })).resolves.toEqual(entered)
|
||||
request(opened, null)
|
||||
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 2, messages: [proposed] })).resolves.toEqual(entered)
|
||||
startStep(opened.session, 1)
|
||||
await expect(proposeStep(opened.ctx, opened.agent, { turn: 2, step: 1, messages: [proposed] })).resolves.toEqual(entered)
|
||||
|
||||
expect(worktreeOf(opened)).toBeNull()
|
||||
expect(opened.changes).toEqual([])
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
expect(await branches(repo)).toEqual(['main'])
|
||||
expect(opened.agent.inbox.nextStep).toEqual([])
|
||||
})
|
||||
|
||||
it('passes the first step proposal through once the requested worktree exists', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
const created = await opened.creations.create(opened.agent, null, signal, false)
|
||||
const reports = opened.changes.length
|
||||
const proposed = prompt('go')
|
||||
|
||||
const decision = await proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [proposed] })
|
||||
|
||||
expect(decision).toEqual({ kind: 'enter', messages: [proposed] })
|
||||
expect(opened.changes).toHaveLength(reports)
|
||||
expect(opened.records).toHaveLength(2)
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(created.path)
|
||||
expect(await branches(repo)).toEqual([created.branch, 'main'])
|
||||
})
|
||||
|
||||
it('ends the turn with the failure and keeps the request open for the next turn', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, 'later')
|
||||
startTurn(opened.session, 1)
|
||||
|
||||
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')] }))
|
||||
.rejects.toMatchObject({ name: 'CreationFailure', branch: GENERATED_BRANCH, phase: 'prepare', reason: MISSING_BASE })
|
||||
const failed = opened.changes[0]?.branch
|
||||
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: 'later' }, locked: true })
|
||||
expect(opened.records).toEqual([{ action: 'request', from: 'later' }])
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
|
||||
await git(repo, 'branch', 'later')
|
||||
startTurn(opened.session, 2)
|
||||
const proposed = prompt('again')
|
||||
const entered = await proposeStep(opened.ctx, opened.agent, { turn: 2, step: 1, messages: [proposed] })
|
||||
|
||||
const created = worktreeOf(opened)
|
||||
expect(created).toMatchObject({ from: 'later', baseCommit: await git(repo, 'rev-parse', 'later') })
|
||||
expect(created?.branch).not.toBe(failed)
|
||||
expect(entered).toEqual({ kind: 'enter', messages: [proposed, notice(created?.path ?? '')] })
|
||||
})
|
||||
|
||||
it('ends the turn with its cancellation reason when the turn is cancelled during the attempt', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo)
|
||||
request(opened, null)
|
||||
const held = hold(opened.sandbox, 'check-ref-format')
|
||||
const controller = new AbortController()
|
||||
const cause: AgentCancelCause = { kind: 'user' }
|
||||
|
||||
const settled = rejectionOf(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')], signal: controller.signal }))
|
||||
await held.reached
|
||||
controller.abort(cause)
|
||||
held.release()
|
||||
|
||||
expect(await settled).toBe(cause)
|
||||
expect(opened.changes.at(-1)).toBeNull()
|
||||
expect(opened.records).toEqual([{ action: 'request', from: null }])
|
||||
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: null } })
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
expect(await branches(repo)).toEqual(['main'])
|
||||
})
|
||||
|
||||
it('fails the proposal and keeps the request open without a worktree service', async () => {
|
||||
const repo = await project()
|
||||
const opened = await creating(repo, { worktrees: false })
|
||||
request(opened, null)
|
||||
|
||||
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')] }))
|
||||
.rejects.toThrow('No worktree service is mounted')
|
||||
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: null } })
|
||||
expect(opened.changes).toEqual([])
|
||||
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.client.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
|
||||
},
|
||||
"include": [
|
||||
"src/client",
|
||||
"src/css-modules.d.ts",
|
||||
"src/types.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../api/remotes/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../api/session-controller/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/locale/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/store"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-conversation/tsconfig.client.json"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-primitives"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-renderer"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-session"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-sidebar-right"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-slots"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-workspace"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
},
|
||||
{
|
||||
"path": "../forge"
|
||||
},
|
||||
{
|
||||
"path": "../forge-session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types",
|
||||
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
|
||||
},
|
||||
"files": [
|
||||
"src/index.ts",
|
||||
"src/git.ts",
|
||||
"src/panel.ts",
|
||||
"src/parse.ts",
|
||||
"src/types.ts",
|
||||
"src/worktree.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-controller"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox"
|
||||
},
|
||||
{
|
||||
"path": "../../sandbox/sandbox-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-projection"
|
||||
},
|
||||
{
|
||||
"path": "../../session/working-directory"
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../util/values"
|
||||
},
|
||||
{
|
||||
"path": "../forge"
|
||||
},
|
||||
{
|
||||
"path": "../forge-session"
|
||||
},
|
||||
{
|
||||
"path": "../worktree"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.host.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.client.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { clientBundle } from '../../client/tsdown.client.ts'
|
||||
|
||||
export default clientBundle(import.meta.dirname, ['lib/types/index.js'], { hostPhase: true })
|
||||
@@ -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/forge-bundle/README.md
|
||||
/:
|
||||
en: cc727e6b70e62904
|
||||
zh: d86d2a43dd64e5c8
|
||||
/deepseek-ai-dsh-experimental-forge-bundle:
|
||||
en: 567570af44a77fa2
|
||||
zh: 3d7722fe3ade65d6
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/summary:
|
||||
en: cd022525a06668aa
|
||||
zh: 4098a47146a7fff7
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/table-of-contents:
|
||||
en: d152484eb41ac6b4
|
||||
zh: 2f8a621b991260a1
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/use-this-package:
|
||||
en: 1abdf65b302f9181
|
||||
zh: 498d89e30835a711
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/understand-the-implementation:
|
||||
en: 6effcbf40264388b
|
||||
zh: b4f093955671c650
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/further-exploration:
|
||||
en: ab1f524b8ad79c55
|
||||
zh: 137cfcb6d192c269
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/model-experience:
|
||||
en: 0e8d555c9ba51844
|
||||
zh: 1aeda456da13a9bd
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/model-experience/kv-cache-effect:
|
||||
en: 06c8b68589e27fc7
|
||||
zh: 092fd7649b34bc04
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/known-limitations-and-deferred-work:
|
||||
en: c8122ac0c46b9a87
|
||||
zh: 436edc8dca640222
|
||||
/deepseek-ai-dsh-experimental-forge-bundle/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
description: "Enable the pull request panel and worktree Sessions from the Plugins page."
|
||||
kind: "package-bundle"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-forge-bundle
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
## Summary
|
||||
|
||||
Show the pull requests each Session creates with their checks and reviews, and let a new Session start in its own Git worktree. Enable Pull requests & worktrees on the Web or Desktop Plugins page; shipped profiles leave it disabled. GitHub status comes from the `gh` CLI signed in on the Host; without it, the panel still shows the branch and the worktree option still works.
|
||||
|
||||
## 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)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## Use this package
|
||||
|
||||
Open Plugins and enable Pull requests & worktrees in the Official group. In a Git workspace, check Worktree in the composer before the first message to start the Session in a new worktree. In a Session whose directory is inside a Git checkout, the header button opens the panel. Pull requests that the Session creates with `gh pr create`, or that a later `git push` updates, appear without further steps. Run `gh auth login --hostname <host>` in a terminal to sign in; the panel shows the command when sign-in is missing. The [panel package](../client-ui-forge/README.md#use-this-package) describes each control.
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## Understand the implementation
|
||||
|
||||
<details>
|
||||
<summary>Maintainer details — click to expand</summary>
|
||||
|
||||
The bundle patch inserts the forge registry, the GitHub provider, and the Session bindings as Host rows, and a group that isolates the `worktrees` service, holding a worktree service row and the panel's two-face package, whose Client half the Web and Desktop clients load while the row is enabled. The isolation lets the bundle coexist with the Git Worktrees bundle, whose model tool keeps its own service. The rows need the Session projection registry, working-directory, filesystem, subprocess, and sandbox services that every base-backed profile composes.
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## Further Exploration
|
||||
|
||||
[Forge subsystem](../../../docs/subsystems/forge.md) · [Session bindings](../forge-session/README.md) · [Panel](../client-ui-forge/README.md)
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## Model Experience
|
||||
|
||||
None, as the composed packages add no model tool or prompt; the directory notice of a Session that starts in a worktree reaches the model through the working-directory service, as described by the [panel](../client-ui-forge/README.md#model-experience).
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None from the bundle; see the [panel](../client-ui-forge/README.md#model-experience) for the worktree notice.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- GitHub is the only code host provider, and the bundle serves github.com only; a GitHub Enterprise host needs a profile patch that lists it in the `forge-github` row's [`hosts`](../forge-github/README.md#use-this-package). No provider recognizes GitLab or other hosts.
|
||||
- The model has no tool for bound pull requests yet.
|
||||
|
||||
-----
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### Dev Note
|
||||
|
||||
<details>
|
||||
<summary>Working context for maintainers — click to expand</summary>
|
||||
|
||||
None.
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,77 @@
|
||||
---
|
||||
description: "从插件页面启用 pull request 面板与 worktree Session。"
|
||||
kind: "package-bundle"
|
||||
---
|
||||
|
||||
# @deepseek-ai/dsh-experimental-forge-bundle
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
## 概述
|
||||
|
||||
展示每个 Session 创建的 pull request 及其检查与评审状态,并让新 Session 在自己的 Git worktree 中开始。在 Web 或 Desktop 的插件页面启用“PR 与工作树”;发布的 profile 默认禁用该功能。GitHub 状态来自宿主上已登录的 `gh` 命令行;没有它时,面板仍会显示分支,工作树选项也照常可用。
|
||||
|
||||
## 目录
|
||||
|
||||
- [使用本包](#use-this-package)
|
||||
- [了解实现](#understand-the-implementation)
|
||||
- [深入探索](#further-exploration)
|
||||
- [模型体验](#model-experience)
|
||||
- [已知限制与待办工作](#known-limitations-and-deferred-work)
|
||||
- [开发备注](#dev-note)
|
||||
|
||||
-----
|
||||
|
||||
<a id="use-this-package"></a>
|
||||
## 使用本包
|
||||
|
||||
打开插件页面,在官方分组中启用“PR 与工作树”。在 Git 工作区中,发出第一条消息之前在输入框中勾选“工作树”,Session 就会在新的 worktree 中开始。Session 的目录位于 Git 检出中时,标题栏按钮会打开面板。Session 用 `gh pr create` 创建的 pull request,以及之后 `git push` 更新的 pull request,无需额外操作即会出现。在终端运行 `gh auth login --hostname <host>` 登录;缺少登录时,面板会显示这条命令。[面板包](../client-ui-forge/README.zh.md#use-this-package)说明了每个控件。
|
||||
|
||||
-----
|
||||
|
||||
<a id="understand-the-implementation"></a>
|
||||
## 了解实现
|
||||
|
||||
<details>
|
||||
<summary>维护者细节——点击展开</summary>
|
||||
|
||||
Bundle patch 以宿主行插入 forge 注册表、GitHub 提供方与 Session 绑定,并插入一个隔离 `worktrees` 服务的分组,其中包含一个 worktree 服务行与面板的双端包;该行启用期间,Web 与 Desktop 客户端会加载其客户端部分。隔离让本套件可以与 Git Worktrees 套件共存,后者的模型工具保有自己的服务。这些行依赖每个基于 base 的 profile 都会组合的 Session 投影注册表、工作目录、文件系统、子进程与沙箱服务。
|
||||
|
||||
</details>
|
||||
|
||||
-----
|
||||
|
||||
<a id="further-exploration"></a>
|
||||
## 深入探索
|
||||
|
||||
[Forge 子系统](../../../docs/subsystems/forge.zh.md) · [Session 绑定](../forge-session/README.zh.md) · [面板](../client-ui-forge/README.zh.md)
|
||||
|
||||
-----
|
||||
|
||||
<a id="model-experience"></a>
|
||||
## 模型体验
|
||||
|
||||
无:组合的包不增加模型工具或提示词;以 worktree 开始的 Session 的目录通知通过工作目录服务告知模型,详见[面板](../client-ui-forge/README.zh.md#model-experience)。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
Bundle 本身没有影响;worktree 通知见[面板](../client-ui-forge/README.zh.md#model-experience)。
|
||||
|
||||
## 已知限制与待办工作
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 只有 GitHub 一个代码托管提供方,且本 Bundle 只服务 github.com;GitHub Enterprise 主机需要用 profile patch 把它列入 `forge-github` 行的 [`hosts`](../forge-github/README.zh.md#use-this-package)。没有提供方识别 GitLab 等其他平台。
|
||||
- 模型目前还没有读取已绑定 pull request 的工具。
|
||||
|
||||
-----
|
||||
|
||||
<a id="dev-note"></a>
|
||||
### 开发备注
|
||||
|
||||
<details>
|
||||
<summary>维护者的工作上下文——点击展开</summary>
|
||||
|
||||
无。
|
||||
|
||||
</details>
|
||||
@@ -0,0 +1,17 @@
|
||||
- insert:
|
||||
- id: forge
|
||||
name: '@deepseek-ai/dsh-experimental-forge'
|
||||
- id: forge-github
|
||||
name: '@deepseek-ai/dsh-experimental-forge-github'
|
||||
- id: forge-session
|
||||
name: '@deepseek-ai/dsh-experimental-forge-session'
|
||||
- id: optional-forge-panel
|
||||
name: cordis:group
|
||||
group: true
|
||||
isolate:
|
||||
worktrees: true
|
||||
config:
|
||||
- id: forge-worktree
|
||||
name: '@deepseek-ai/dsh-experimental-worktree'
|
||||
- id: forge-panel
|
||||
name: '@deepseek-ai/dsh-experimental-client-ui-forge'
|
||||
@@ -0,0 +1,11 @@
|
||||
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="7" y="7" width="22" height="22" rx="4" fill="url(#forge-bundle-card)" fill-opacity="0.85" />
|
||||
<circle cx="13" cy="12.5" r="1.9" stroke="#FFFFFF" stroke-width="1.6" />
|
||||
<circle cx="13" cy="23.5" r="1.9" stroke="#FFFFFF" stroke-width="1.6" />
|
||||
<circle cx="23" cy="23.5" r="1.9" stroke="#FFFFFF" stroke-width="1.6" />
|
||||
<path d="M13 14.4V21.6M23 21.6V16.2C23 14.3 21.7 13 19.8 13H18" stroke="#FFFFFF" stroke-width="1.6" />
|
||||
<path d="M20 11L18 13L20 15" stroke="#FFFFFF" stroke-width="1.6" stroke-linejoin="round" />
|
||||
<defs>
|
||||
<linearGradient id="forge-bundle-card" x1="18" y1="7" x2="18" y2="29" gradientUnits="userSpaceOnUse"><stop stop-color="#69B9FF" /><stop offset="1" stop-color="#324DE2" /></linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 842 B |
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "Pull requests & worktrees",
|
||||
"description": "Track the pull requests each Session creates, with their checks and reviews, and start new Sessions in a Git worktree; uses the gh CLI for GitHub"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"meta": {
|
||||
"title": "PR 与工作树",
|
||||
"description": "跟踪每个会话创建的 Pull Request 及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-experimental-forge-bundle",
|
||||
"description": "Experimental pull request panel and worktree option for Sessions in Git repositories",
|
||||
"version": "0.2.1-rc.2",
|
||||
"icon": "./icon.svg",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/experimental/forge-bundle"
|
||||
},
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json",
|
||||
"./cordis.patch.yml": "./cordis.patch.yml",
|
||||
"./locale/*.json": "./locale/*.json"
|
||||
},
|
||||
"files": [
|
||||
"icon.svg",
|
||||
"locale/*.json",
|
||||
"lib/index.js",
|
||||
"cordis.patch.yml",
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-experimental-client-ui-forge": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-forge": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-forge-github": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-forge-session": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-worktree": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:~"
|
||||
},
|
||||
"dsh": {
|
||||
"bundle": {
|
||||
"patch": "./cordis.patch.yml"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Experimental forge panel composition; runtime rows live in cordis.patch.yml. */
|
||||
export {}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
export default defineConfig({ entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', fixedExtension: false, dts: false, clean: false })
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user