Merge pull request #5961 from deepseek-harness/worktree/forge-pr-worktree-panel

feat(forge): add the pull request panel and worktree Sessions
This commit is contained in:
Yichen Jiang authored and GitHub committed 2026-10-26 12:40:20 +00:00
commit 9dd1ecc1f4
230 files changed
+24576 -419

No files matched your search

@@ -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-change-request-panel-and-worktree-sessions:
en: a524673f0d9cc9d8
zh: db8a2c1ad8f9c8a3
/agent-note-change-request-panel-and-worktree-sessions/problem:
en: 344bcc7e9825bbc8
zh: c1d0f4b54f5b3786
/agent-note-change-request-panel-and-worktree-sessions/decision:
en: 494b10799ca74ab7
zh: fd55bf793ee6c643
/agent-note-change-request-panel-and-worktree-sessions/deferred:
en: 32ef8af85f4d2f66
zh: b82b67f94346399e
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
en: aa2ae6ca5d297127
zh: b7eba2e7deffe245
/agent-note-change-request-panel-and-worktree-sessions/consequences:
en: 97976f073b9f65e0
zh: 95be3f7f375b0876
@@ -0,0 +1,84 @@
# Agent Note: Change 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 change requests, but nothing shows it. A user who asks the agent to open change 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 New Session option, a creation card above the composer, and a toast. Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. 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 the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. 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 Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default.
An expanded card marks a draft ready for review and merges a change request the host would merge now. The forge seam carries both as provider actions that return a typed failure, and every snapshot says whether the host would merge the change request now for the signed-in user and which methods the repository allows, so the panel offers only actions that can succeed. A merge carries the head commit the card showed, and the host refuses it once new commits arrived. The panel acts only on change requests bound to the Session.
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's turn starts, so the turn runs there and its first request already names the new directory. When the message enters the Session's inbox, the panel's `agent/inbox/inserted` listener starts maintenance of the Agent before the delivery wakes it; the Agent latches the wake until the maintenance ends, and the panel creates and enters the worktree inside it. The creation's stage streams to a card above the composer, where Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting in the inbox, and the card keeps the failure and offers another attempt with a new branch name or Continue without a worktree, which withdraws the request and lets the message go on. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because a message that enters while the Agent runs a turn or other maintenance gets no hold; 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 tab's first row 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 every message that enters while the Agent is idle starts a hold that checks again, so the message waits without starting its turn while the directory is missing. The check must come before the turn: while it assembles a step's context, the working-directory service moves a Session whose directory is gone back to its project, before any pre-step listener runs. 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. Either runs inside the hold and lets the waiting message go on.
The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`.
The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other.
## 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.
The changes view offers no scope for one commit or for the last turn yet; both would be further `ForgeLocalScope` kinds read through `localDiff`.
## Alternatives considered
**Per-host nouns.** Calling items pull requests on GitHub and merge requests on GitLab would need each provider to declare its noun and every string to take it; GitHub is the only provider, and one term keeps the copy fixed.
**Merging through `gh pr merge`.** It enables auto-merge while required checks are pending and joins a merge queue when the base requires one, so a click could schedule a later merge instead of merging. The REST merge endpoint merges at once or refuses.
**Deleting the head branch after a merge.** The Session's worktree can be on that branch.
**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 turn has started when the first step proposal runs: its context was assembled for the old directory, a failure can only end the turn and drop the claimed message, and the working-directory service has already moved a Session out of a removed worktree. It remains the fallback for a message no hold took.
**Holding the message at Agent admission.** An `agent-controller/admission` stage would hold a message before it enters the inbox, so the composer keeps it until the worktree exists and a failure returns it for editing. It needs a controller service in front of every sender's delivery, a core addition that one panel does not justify; a hold at inbox insertion uses the existing inbox event and maintenance, and covers every sender.
**Returning a waiting message to the composer.** Once the Host accepted a message the composer cannot take it back without a new public API in the conversation package for one plugin; the message keeps waiting with the card's actions instead.
**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. A hold at inbox insertion keeps the message where it enters the Agent and 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`, the `gh pr` commands that act on one change request, pushes, and the branch the checkout tracks, and `track` remains for Undo.
**Parsing patches in the browser.** The Remote would carry unbounded patch text, and the browser would need a patch parser; the Host bounds the read and parses it once.
**Reusing the review tab's file comparison.** It belongs to another plugin, which this package may not import at run time, and moving it into the shared primitives would change a shared package for one consumer; the view is built from the shared highlighter, menu, tooltip, and diff color tokens instead.
**Reading a change request's diff from the local repository.** The checked-out branch can be ahead of or behind the host, and the base may not be fetched; the host's diff is what reviewers see. The view says when the checked-out branch has commits beyond the head instead.
**Recognizing a too-large diff from the failure message in the panel.** The message belongs to the provider and its host and changes with them; a failure code lets each provider report its own limits, and the panel branches on the code.
**Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests.
## 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 eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. 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. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once.
@@ -0,0 +1,84 @@
# Agent Note: 变更请求面板与 worktree Session
Status: implemented
[English](2026-10-09-forge-panel.md) | 中文
## Problem
[Session 变更请求绑定](2026-10-09-session-change-request-bindings.zh.md)为每个 Session 提供了变更请求列表,但没有地方展示。让 agent 开变更请求的用户需要在不离开对话的情况下看到它们的检查、评审与能否合并,也希望新 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 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。
Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。
展开的卡片可以把草稿标记为可评审,也可以合并主机现在就会合并的变更请求。forge seam 把两者作为提供方操作承载,失败时返回带类型的结果;每个快照都说明主机现在是否会为已登录用户合并该变更请求、仓库允许哪些合并方式,因此面板只提供能够成功的操作。合并带上卡片显示的 head 提交,有新提交到达后主机会拒绝。面板只操作绑定到该 Session 的变更请求。
新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息的这一轮开始之前就存在,这样这一轮才会在其中运行,第一次请求也已经写明新目录。消息进入 Session 的收件箱时,面板的 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Agent 的维护任务;Agent 会暂存唤醒直到维护任务结束,面板在维护任务内创建并进入 worktree。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止这次尝试。尝试被停止或失败后,消息继续在收件箱中等待,卡片保留失败信息,并提供用新分支名再试一次,或“不使用工作树”:撤回请求并让消息继续。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为 Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。标签页顶部一行写出 Session 所在的分支。
Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;Agent 空闲时进入的每条消息都会启动一次暂留并再确认一次,目录缺失期间消息等待、不开始这一轮。这次确认必须发生在这一轮之前:组装一个步骤的上下文时,工作目录服务会把目录已不存在的 Session 移回其项目,而这发生在任何 pre-step 监听器运行之前。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。两者都在暂留内运行,之后放行等待中的消息。
面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。
面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。
## 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`。
- **设置**——在套件的配置页中选择提供方与主机。
变更视图目前还没有单个提交与上一轮的范围;两者将是经 `localDiff` 读取的更多 `ForgeLocalScope` 种类。
## Alternatives considered
**按平台使用不同名称。** 在 GitHub 上称 pull request、在 GitLab 上称 merge request,需要每个提供方声明自己的名称,所有文案也要随之变化;而 GitHub 是唯一的提供方,统一的术语让文案保持不变。
**通过 `gh pr merge` 合并。** 必需检查尚未完成时它会启用自动合并,基准分支要求合并队列时它会加入队列,因此一次点击可能安排了一次以后的合并而不是立即合并。REST 合并接口要么立即合并,要么拒绝。
**合并后删除 head 分支。** Session 的 worktree 可能就在这个分支上。
**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。
**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。
**勾选时立即创建 worktree。** 每次切换勾选或更换基准都会创建并丢弃一个检出,而且用户决定发送之前 Session 就已经移动。
**在创建 Session 的请求中携带该选项。** 这个请求由 Session 控制器拥有,字段是封闭的;插件记录让选项留在本包之内,并且在空白 Session 重新加载后仍然保留。
**在面板中切换现有 worktree。** 切换后的 Session 仍保留其项目的沙箱根,因此项目之外的 worktree 依然只读;对话中途移动还会让模型对文件的认识前后割裂。让 Session 从一开始就在自己的 worktree 中运行可以同时避免这两点。
**把选项放在输入框工具栏。** 发出第一条消息后工具栏依然存在,而那时该选项已不再适用;新会话那一行在 Session 开始后就会消失。那一行原先只提供单值 slot,因此 `ui-conversation` 增加了列表型 slot `conversation.hero.options`。
**在第一轮之内创建 worktree。** 第一个步骤提议运行时这一轮已经开始:上下文按旧目录组装,失败只能结束这一轮并丢弃已认领的消息,而工作目录服务已经把 Session 移出被删除的 worktree。它只作为没有被暂留的消息的兜底。
**在 Agent 接纳阶段暂留消息。** `agent-controller/admission` 阶段会在消息进入收件箱之前暂留它,因此 worktree 建好之前消息一直留在输入框里,失败时退回以便编辑。它需要在每个发送方的投递之前加一个控制器服务,单个面板不足以支撑这项核心改动;在收件箱插入时暂留只用现有的收件箱事件与维护任务,并覆盖所有发送方。
**把等待中的消息退回输入框。** 宿主接收消息之后,输入框没有办法把它收回,除非为一个插件在会话包里新增公共 API;消息改为在卡片的操作下继续等待。
**在 Web 输入框里设发送闸门。** 浏览器在宿主看到消息之前就拦住它,因此每次检查都要一次 Remote 调用,被拒绝消息的阶段只存在于浏览器中,其他发送方也会绕过它。在收件箱插入时暂留,让消息停在它进入 Agent 的位置,并把失败保留在面板状态中。
**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。
**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。
**复用代码审查标签页的文件对比。** 它属于另一个插件,本包在运行时不能导入;把它移入共享基础组件又会为一个使用方改动共享包。变更视图改用共享的高亮器、菜单、提示与 diff 颜色 token 构建。
**从本地仓库读取变更请求的 diff。** 当前检出分支可能领先或落后于托管平台,基准分支也可能没有拉取;评审者看到的是托管平台的 diff。视图改为在当前检出分支有超出 head 的提交时给出说明。
**在面板里根据失败信息识别 diff 过大。** 失败信息属于提供方及其托管平台,会随它们变化;失败代码让每个提供方报告自己的上限,面板只按代码分支。
**直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。
## Consequences
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。
@@ -9,11 +9,11 @@
en: e736740546fb8219
zh: 46a16a7a633716ec
/agent-note-session-change-request-bindings/decision:
en: c91bbf1c6e224e2e
zh: 8c48d97146457381
en: 2bbd058f4edc8bb5
zh: 5cc12bb0aa0864e2
/agent-note-session-change-request-bindings/alternatives-considered:
en: 15fdaef4e432fc72
zh: 8d36c859bd89f403
/agent-note-session-change-request-bindings/consequences:
en: 932453846bdb0c0a
zh: 14068a18104ece4d
en: a2960e50ec52c9b6
zh: fd23f4fe1137787d
@@ -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, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results record the branches they updated. Those three commands act on one change request, so only the URL given as their first argument and the URL their output prints for that change request count; links in a description name other change requests. 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, and when a caller such as the forge panel reports the branch a Session's checkout tracks, the service looks up open change requests for that branch 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
@@ -28,4 +28,4 @@ After each push result, the service looks up open change requests for the pushed
## Consequences
Bindings appear without model cooperation for the common `gh pr create` and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere appears only after another push or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host.
Bindings appear without model cooperation for the common `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere for a branch appears only after another push, another report of that branch, or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host.
@@ -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,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果记录其更新的分支。这三个命令只操作一个变更请求,因此只计入作为第一个参数给出的 URL 和输出为该变更请求打印的 URL;描述正文里的链接指向的是其他变更请求。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。
每次推送结果之后,服务通过提供方为被推送的分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。
每次推送结果之后,以及 forge 面板等调用方报告 Session 检出所跟踪的分支时,服务通过提供方为该分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。
## Alternatives considered
@@ -28,4 +28,4 @@ Status: implemented
## Consequences
在常见的 `gh pr create` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处打开的变更请求要等到下一次推送或 `track` 才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。
在常见的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处为某个分支打开的变更请求,要等到下一次推送、再次报告该分支或 `track` 之后才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。
@@ -9,14 +9,14 @@
en: 46916488210ba493
zh: 98107c3805a22b2c
/agent-note-dual-release-install-layout-work-budget/decision:
en: c432b68c4ad1b3c1
zh: a11e958fa68dc2f3
en: b900469750b27f81
zh: d5380d7751332e9c
/agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs:
en: 36d3e3ef39cbba2f
zh: 2480acf6260817b3
en: 463df1fae15ae2fc
zh: 733c9ddb4900e3fc
/agent-note-dual-release-install-layout-work-budget/alternatives-considered:
en: 29987b7634c84d28
zh: a1469e4b6aaa46e2
/agent-note-dual-release-install-layout-work-budget/consequences:
en: 8dffc0c3fa699980
zh: 99ec25001e5aa716
en: 4226e2216abe042d
zh: db8f308ef40f47e9
@@ -10,14 +10,16 @@ The `Dependency layout` lane packs two incompatible synthetic DSH releases into
## Decision
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 1_134_400`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (about 1,180 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it.
## What the resolution costs
Measured 2026-10-26 on an idle Apple M5 Pro with Node 24.17.0 and npm 11.13.0, using `buildDualDshRegistry`, `resolveNpmPackageLock`, and `assertDualDshInstallLayout` sequentially with fresh temporary consumers: the #5960 graph (315 packages per release, 2782 internal edges, 876,330 work units) resolved in 75.27 s; the #5961 graph (320 packages, 2836 edges, 907,520 units) resolved in 71.03 s, with a second verification at 97.73 s. All placements passed, shared one Cordis installation, and requested no archives. The measurements calibrate graph admission and do not establish a runtime improvement. The current budget is 1.25 times its 907,520 units; the earlier, slower per-unit timing remains the conservative hang-guard calibration.
Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`:
| Phase | Cost |
@@ -47,4 +49,4 @@ Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time,
## Consequences
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to about 1,180 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
@@ -10,14 +10,16 @@ Status: implemented
## Decision
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 1_134_400` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(约 1,180 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。
## What the resolution costs
2026-10-26 在空闲 Apple M5 Pro 上使用 Node 24.17.0 与 npm 11.13.0,通过 `buildDualDshRegistry`、`resolveNpmPackageLock` 和 `assertDualDshInstallLayout`,对独立的新建临时消费者目录依次测量:#5960 的依赖图(每个发布 315 个包、2782 条内部边、876,330 个工作量单位)用 75.27 秒解析;#5961 的依赖图(320 个包、2836 条边、907,520 个工作量单位)用 71.03 秒解析,第二次验证耗时 97.73 秒。所有布局校验均通过,共享一个 Cordis 安装,未请求包归档。这些测量用于校准依赖图的准入规模,不代表运行时性能提升。当前预算为其 907,520 个工作量单位的 1.25 倍;挂起保护仍采用此前较慢的单位工作量耗时作为保守校准。
2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得:
| 阶段 | 开销 |
@@ -47,4 +49,4 @@ npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION
## Consequences
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多约 1,180 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
+1
View File
@@ -134,6 +134,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`semver`](https://github.com/npm/node-semver) | ISC |
| [`serve-static`](https://github.com/expressjs/serve-static) | MIT |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`shell-quote`](http://github.com/ljharb/shell-quote) | MIT |
| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
+2 -1
View File
@@ -108,7 +108,8 @@
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*"
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-skill-badge": "workspace:*",
+2 -1
View File
@@ -72,7 +72,8 @@
"@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:*"
},
"dependencies": {
"@deepseek-ai/dsh-message-quotes": "workspace:*"
@@ -0,0 +1,20 @@
- img "变更请求状态:需要处理"
- text: "Add the pull request panel #7"
- button "在浏览器中打开"
- button "全部 2 个文件"
- text: +4 −2
- button "刷新变更"
- button "全部折叠"
- button "左右对比" [pressed]
- button "自动换行" [pressed]
- button "收起 panel.ts" [expanded]
- text: src/panel.ts +2 −2
- button "panel.ts 的更多操作"
- text: 1 export const n1 = 1 1 export const n1 = 1 2 export const n2 = 2 2 export const n2 = 'two' 3 export const n3 = 3 3 export const n3 = 3 4 export const n4 = 4 4 export const n4 = 4 5 export const n5 = 5 5 export const n5 = 5
- button "6 行未变更"
- text: 12 export const n12 = 12 12 export const n12 = 12 13 export const n13 = 13 13 export const n13 = 13 14 export const n14 = 14 14 export const n14 = 14 15 export const n15 = 15 15 export const n15 = 'fifteen' 16 export const n16 = 16 16 export const n16 = 16 17 export const n17 = 17 17 export const n17 = 17 18 export const n18 = 18 18 export const n18 = 18
- button "其余未变更的行"
- button "收起 panel.md" [expanded]
- text: docs/panel.md 新文件 +2 −0
- button "panel.md 的更多操作"
- text: "1 + # Panel 2 + Shows the change requests of a Session."
@@ -0,0 +1,7 @@
- menu:
- text: "将 #9 合并到 main"
- menuitem "压缩合并 把全部提交压缩成一个提交"
- menuitem "创建合并提交 保留全部提交,并新增一个合并提交"
- menuitem "变基合并 把提交逐个变基到 main"
- separator
- menuitem "确认合并"
@@ -0,0 +1,7 @@
- menu:
- text: "将 #9 合并到 main"
- menuitem "压缩合并 把全部提交压缩成一个提交"
- menuitem "创建合并提交 保留全部提交,并新增一个合并提交"
- menuitem "变基合并 把提交逐个变基到 main"
- separator
- menuitem "提交已更新,请重新检查后再打开合并菜单" [disabled]
@@ -0,0 +1,17 @@
- text: dsh-{{worktree}} 工作树 · 基于 release
- button "复制工作树路径"
- heading "本会话的变更请求 · 1" [level=3]
- button "刷新状态"
- list:
- listitem:
- img "变更请求状态:需要处理"
- link "Add the pull request panel":
- /url: https://github.com/octo/demo/pull/7
- text: "#7 feat → main"
- button "在浏览器中打开"
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
@@ -0,0 +1,12 @@
- listitem:
- img "变更请求状态:需要处理"
- link "Add the pull request panel":
- /url: https://github.com/octo/demo/pull/7
- text: "#7 feat → main"
- button "在浏览器中打开"
- button "复制变更请求链接"
- 'button "从本会话移除 #7"'
- 'button "收起 #7" [expanded]'
- button "检查 1 项失败 · 1 项通过"
- text: 评审 等待评审 · 1 条未解决讨论 合并 无冲突
- 'button "查看 #7 的变更:新增 128 行,删除 14 行"': 变更 +128 −14
@@ -42,6 +42,11 @@
- text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
- status
- switch "启用 变更请求与工作树"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -42,6 +42,11 @@
- text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
- status
- switch "启用 变更请求与工作树"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -42,6 +42,11 @@
- text: 实验性 提供“自动审查”访问模式:每次工具调用前,由模型对照你的要求检查,获准的调用立即运行,被拒绝的调用等你审批。
- status
- switch "启用 自动授权审查"
- listitem:
- button "查看 变更请求与工作树": 变更请求与工作树
- text: 实验性 跟踪每个会话的变更请求及其检查与评审,新会话也可以在 Git 工作树中开始;GitHub 通过 gh 命令行访问。
- status
- switch "启用 变更请求与工作树"
- heading "已安装插件" [level=3]
- list:
- listitem:
@@ -6,7 +6,7 @@ Focus at 500ms:
- tooltip "刷新"
failure: pending
- button "刷新" [disabled]
{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]}
failure: settled
- button "刷新"
{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]}
+629
View File
@@ -0,0 +1,629 @@
/** Worktree Sessions and the change 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 { homedir, 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 { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
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'
/** A draft change request that the double lets the panel mark ready and merge. */
const DRAFT = 'https://github.com/octo/demo/pull/9'
/** Message sent after the worktree is removed, which waits until the worktree is restored. */
const FOLLOW_UP = 'Summarize the failed check'
/** Reply to {@link FOLLOW_UP}, a model call appended after the borrowed scenario's single one. */
const FOLLOW_UP_REPLY = 'RESTORED'
const run = promisify(execFile)
/**
* GitHub CLI double: signed in to github.com, with octo/demo allowing every
* merge method and the viewer's usual one squash. GraphQL serves pull request
* 7 with one failed and one passed check run, one unresolved thread, and a
* required review, and pull request 9, an approved draft whose checks passed.
* `gh pr ready 9` and the REST merge of pull request 9 change what later
* queries report, through `state.json`. `gh pr diff N` prints `pr-N.diff`, or
* fails with `pr-N.refused` on standard error when that file exists, and the raw
* contents API serves `contents.json`, keyed by `path@commit`. Every invocation
* is appended to `calls.jsonl`.
*/
const GH = `#!/usr/bin/env node
import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const args = process.argv.slice(2)
const here = dirname(fileURLToPath(import.meta.url))
appendFileSync(join(here, 'calls.jsonl'), JSON.stringify(args) + '\\n')
const statePath = join(here, 'state.json')
const state = existsSync(statePath) ? JSON.parse(readFileSync(statePath, 'utf8')) : { ready: false, mergedAt: null }
const save = () => { writeFileSync(statePath, JSON.stringify(state)) }
if (args[0] === 'pr' && args[1] === 'view' && args[2] === '7') {
const fields = args[args.indexOf('--json') + 1]
process.stdout.write(JSON.stringify(fields === 'url'
? { url: 'https://github.com/octo/demo/pull/7' }
: { number: 7, title: 'Add the pull request panel' }))
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'ready' && args[2] === '9') {
state.ready = true
save()
process.stderr.write('Pull request octo/demo#9 is marked as "ready for review"\\n')
process.exit(0)
}
if (args[0] === 'api' && args[5] === 'repos/octo/demo/pulls/9/merge') {
state.mergedAt = new Date().toISOString()
save()
process.stdout.write(JSON.stringify({ sha: 'c'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
process.exit(0)
}
if (args[0] === 'pr' && args[1] === 'diff') {
const refused = join(here, 'pr-' + args[2] + '.refused')
if (existsSync(refused)) {
process.stderr.write(readFileSync(refused, 'utf8'))
process.exit(1)
}
process.stdout.write(readFileSync(join(here, 'pr-' + args[2] + '.diff'), 'utf8'))
process.exit(0)
}
if (args[0] === 'api' && args[3] === '-H' && args[4] === 'Accept: application/vnd.github.raw+json') {
const [resource, query] = args[5].split('?')
const path = decodeURIComponent(resource.slice('repos/octo/demo/contents/'.length))
const text = JSON.parse(readFileSync(join(here, 'contents.json'), 'utf8'))[path + '@' + query.slice('ref='.length)]
if (text === undefined) {
process.stderr.write('gh: Not Found (HTTP 404)\\n')
process.exit(1)
}
process.stdout.write(text)
process.exit(0)
}
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 pulls = {
7: {
number: 7, 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', mergeStateStatus: 'BLOCKED', 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')],
} } } }] },
},
9: {
number: 9, url: 'https://github.com/octo/demo/pull/9', title: 'Add the merge button', state: state.mergedAt === null ? 'OPEN' : 'MERGED',
isDraft: !state.ready, updatedAt: '2026-10-09T00:00:00Z', closedAt: state.mergedAt, author: { login: 'octo' },
headRefName: 'feat/merge', headRefOid: state.head ?? 'b'.repeat(40), baseRefName: 'main', additions: 30, deletions: 2, mergeable: 'MERGEABLE',
mergeStateStatus: state.ready ? 'CLEAN' : 'BLOCKED', reviewDecision: 'APPROVED', reviewThreads: { nodes: [] },
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'SUCCESS', contexts: {
pageInfo: { hasNextPage: false }, nodes: [check('lint', 'SUCCESS', '2026-10-09T00:00:42Z')],
} } } }] },
},
}
const settings = {
squashMergeAllowed: true, mergeCommitAllowed: true, rebaseMergeAllowed: true, viewerPermission: 'WRITE', viewerDefaultMergeMethod: 'SQUASH',
}
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]] = { ...settings }
else data[repository][match[2]] = pulls[Number(match[3])] ?? null
}
process.stdout.write(JSON.stringify({ data }))
process.exit(0)
}
process.stderr.write('unexpected gh invocation\\n')
process.exit(1)
`
/** Head commit of pull request 7 in the GitHub CLI double. */
const HEAD7 = 'a'.repeat(40)
/** What gh 2.100 prints for the diff of pull request 9 when GitHub refuses it for having over 300 files. */
const PR9_REFUSAL = [
'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull '
+ 'requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/9)',
'PullRequest.diff too_large',
'',
].join('\n')
/** Line `n` of `src/panel.ts` before (`old`) or after (`new`) pull request 7, which changes lines 2 and 15 of its 20. */
function panelLine(n: number, side: 'old' | 'new'): string {
if (n === 2) return side === 'old' ? 'export const n2 = 2' : 'export const n2 = \'two\''
if (n === 15) return side === 'old' ? 'export const n15 = 15' : 'export const n15 = \'fifteen\''
return `export const n${n} = ${n}`
}
/** Patch of pull request 7 as `gh pr diff` prints it: two hunks of `src/panel.ts` and the new `docs/panel.md`. */
const PR7_DIFF = [
'diff --git a/src/panel.ts b/src/panel.ts',
'index 1111111..2222222 100644',
'--- a/src/panel.ts',
'+++ b/src/panel.ts',
'@@ -1,5 +1,5 @@',
` ${panelLine(1, 'new')}`, `-${panelLine(2, 'old')}`, `+${panelLine(2, 'new')}`,
...[3, 4, 5].map(n => ` ${panelLine(n, 'new')}`),
'@@ -12,7 +12,7 @@',
...[12, 13, 14].map(n => ` ${panelLine(n, 'new')}`), `-${panelLine(15, 'old')}`, `+${panelLine(15, 'new')}`,
...[16, 17, 18].map(n => ` ${panelLine(n, 'new')}`),
'diff --git a/docs/panel.md b/docs/panel.md',
'new file mode 100644',
'index 0000000..3333333',
'--- /dev/null',
'+++ b/docs/panel.md',
'@@ -0,0 +1,2 @@',
'+# Panel',
'+Shows the change requests of a Session.',
'',
].join('\n')
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 the New Session screen of that workspace. */
interface PanelLaunch {
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` and open the New Session screen of that workspace.
* @param failureShot - name of the screenshot saved when the test fails.
* @param scenario - normal conversation, a restoration follow-up, or a numbered PR lookup.
* @returns the scaffold, page, and checkout.
*/
async function launchPanel(failureShot: string, scenario: 'normal' | 'follow-up' | 'reference' = 'normal'): Promise<PanelLaunch> {
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)
await writeFile(join(root, 'pr-7.diff'), PR7_DIFF)
await writeFile(join(root, 'pr-9.refused'), PR9_REFUSAL)
const panelText = `${Array.from({ length: 20 }, (_, index) => panelLine(index + 1, 'new')).join('\n')}\n`
await writeFile(join(root, 'contents.json'), JSON.stringify({ [`src/panel.ts@${HEAD7}`]: panelText }))
const overlay = join(root, 'forge.patch.yml')
await writeFile(overlay, `- id: forge-github\n config: ${JSON.stringify({ ghCommand: gh })}\n`)
const override = join(root, 'replay.override.json')
// The borrowed scenario records one model call, so index 1 appends the reply to the follow-up.
if (scenario === 'follow-up') await writeFile(override, JSON.stringify({ patches: [{ at: 1, entry: { kind: 'chunks', chunks: textReply(FOLLOW_UP_REPLY) } }] }))
if (scenario === 'reference') {
const command = `PATH=${JSON.stringify(`.fixture-bin:${process.env.PATH ?? ''}`)} gh pr view 7 --repo octo/demo --json number,title 2>&1 | head -200`
const call = { type: 'tool-call', id: 'view-pr', name: 'bash', arguments: JSON.stringify({ command, description: 'View PR metadata' }) }
await writeFile(override, JSON.stringify({ patches: [
{ at: 0, entry: { kind: 'chunks', chunks: [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'block-end', index: 0, block: call },
{ type: 'finish', reason: { kind: 'tool-calls' } },
] } },
{ at: 1, entry: { kind: 'chunks', chunks: textReply('Viewed PR 7.') } },
] }))
}
const scaffold = await launchWebScaffold({
profile: { packages: [{ dir: BUNDLE, enabled: true }] },
extraOverlayPath: overlay,
replayFixture: FIXTURE,
...scenario !== 'normal' ? { replayOverride: override } : {},
// 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', '[email protected]')
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')
if (scenario === 'reference') {
// The model's confined command must own the CLI double's writable call log.
const modelBin = join(project, '.fixture-bin')
await mkdir(modelBin)
await writeFile(join(modelBin, 'gh'), GH)
await chmod(join(modelBin, 'gh'), 0o755)
}
const browser = await chromium.launch()
resources.browser = browser
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)
return { scaffold, page, root, project, tripwire }
}
/** Stream chunks of a model reply that is one text block. */
function textReply(text: string): unknown[] {
return [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text },
{ type: 'block-end', index: 0, block: { type: 'text', text } },
{ type: 'usage', usage: { inputTokens: 50, outputTokens: 2, cacheReadTokens: 0, reasoningTokens: 0 } },
{ type: 'finish', reason: { kind: 'stop' } },
]
}
/**
* Launch the panel and choose the worktree option of the fresh Session from `release`.
* @param failureShot - name of the screenshot saved when the test fails.
* @param followUp - whether the replay answers a second model call.
* @returns the scaffold, page, and checkout.
*/
async function requestReleaseWorktree(failureShot: string, followUp = false): Promise<PanelLaunch> {
const launched = await launchPanel(failureShot, followUp ? 'follow-up' : 'normal')
const { page } = launched
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 launched
}
/**
* 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')('automatically shows a numbered PR when gh JSON omits its URL', async () => {
const { scaffold, page, project, tripwire } = await launchPanel('web-e2e-forge-numbered-reference', 'reference')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
const events = await readPersistedEvents(scaffold, sessionId)
const toolOutput = events.flatMap(event => event.type === 'tool/result'
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []).join('\n')
expect(toolOutput).toContain('Add the pull request panel')
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求需要处理')
await header.click()
const change = page.locator(`[data-forge-change="${PULL}"]`)
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'reference.expected.md'),
await captureStableAria(page, `[data-forge-change="${PULL}"]`, scaffold.workspaceCwd), MODE)
expect(events.flatMap((event) => {
const record = pluginRecordOf(event)
return record?.type === 'plugin:forge-session/binding' ? [record.data] : []
})).toEqual([{ action: 'reference', url: PULL }])
const calls = (await readFile(join(project, '.fixture-bin', 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'view', '7', '--repo', 'octo/demo', '--json', 'url'])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('starts a Session in a worktree before its first message, follows its change request, and restores the removed worktree', async () => {
const { scaffold, page, root, project, tripwire } = await requestReleaseWorktree('web-e2e-forge-panel', true)
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 = basename(directory)
expect(directory).toBe(join(project, '.agents', 'worktrees', branch))
expect(branch).toMatch(new RegExp(`^dsh-${sessionId.slice('session-'.length, 'session-'.length + 8)}-[0-9a-f]{4}$`))
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'))
// A change 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('本会话的变更请求需要处理')
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]')
const checkoutName = checkout.getByText(branch, { exact: true })
await checkoutName.waitFor({ timeout: 20_000 })
await checkout.getByText('工作树 · 基于 release', { exact: true }).waitFor()
await checkoutName.hover()
await page.getByRole('tooltip').filter({ hasText: `工作树(基于 release):${abbreviateHomePath(directory, homedir())}` }).waitFor()
await page.mouse.move(0, 0)
const change = panel.locator(`[data-forge-change="${PULL}"]`)
const title = change.getByRole('link', { name: 'Add the pull request panel', exact: true })
await title.waitFor({ timeout: 20_000 })
// The only open change request starts expanded.
await change.getByText('等待评审 · 1 条未解决讨论', { exact: true }).waitFor()
// The random branch suffix can read as a duration, so the branch is replaced before the capture normalizes durations.
await compareOrRefreshGolden(join(EXPECTED, 'panel.expected.md'),
await captureStableAria(page, '[data-forge-panel]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
const checksRow = change.getByRole('button', { name: /^检查/ })
await checksRow.click()
const checks = page.getByRole('menu')
await expect.poll(() => checks.getByRole('menuitem').allTextContents()).toEqual([expect.stringMatching(/^test/), expect.stringMatching(/^lint/)])
await page.keyboard.press('Escape')
await checks.waitFor({ state: 'detached' })
expect(await checksRow.getAttribute('aria-expanded')).toBe('false')
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: '从本会话移除 #7', exact: true }).click()
await change.waitFor({ state: 'detached' })
await panel.getByText('本会话还没有变更请求', { exact: true }).waitFor()
await page.getByRole('alert').filter({ hasText: '已从本会话移除 #7' }).getByRole('button', { name: '撤销', exact: true }).click()
await title.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 holds the next message in the inbox until the worktree is restored.
await git(project, 'worktree', 'remove', '--force', directory)
const composer = page.locator('[data-composer-input]').first()
await writeComposerDraft(page, composer, FOLLOW_UP)
const accepted = page.waitForResponse('**/api/session/prompt')
await composer.press('Enter')
const envelope = await (await accepted).json() as { readonly result: unknown }
expect(envelope.result).toMatchObject({ ok: true })
const missing = page.locator('[data-forge-missing]')
await missing.getByText(`可以在原位置恢复分支 ${branch} 的工作树。删除前未提交的修改无法恢复`, { exact: true }).waitFor()
await missing.getByText('恢复工作树或在项目目录继续后,消息会接着发送', { exact: true }).waitFor()
await expect.poll(() => checkout.getAttribute('data-missing')).toBe('')
const turns = async (): Promise<number> => (await readPersistedEvents(scaffold, sessionId)).filter(event => event.type === 'turn/start').length
expect(await turns()).toBe(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(() => checkout.getAttribute('data-missing')).toBeNull()
// The waiting message goes on in the restored worktree.
await page.getByText(FOLLOW_UP_REPLY, { exact: true }).waitFor({ timeout: 20_000 })
expect(await turns()).toBe(2)
expect(scaffold.ctx.workingDirectory.get(session)).toBe(directory)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('keeps the first message waiting when the worktree cannot be created, tries again, 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 { 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}-[0-9a-f]{4}$/)
await failure.getByText('消息会在工作树建好后发送', { exact: true }).waitFor()
// Retry makes another attempt with a new branch name while the message keeps waiting.
await card.getByRole('button', { name: '重试', exact: true }).click()
await expect.poll(() => failure.getByRole('definition').first().textContent(), { timeout: 20_000 }).not.toBe(attempted)
await card.getByRole('button', { name: '不使用工作树', exact: true }).click()
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)
await page.locator('[data-forge-header]').click({ timeout: 20_000 })
const checkout = page.locator('[data-forge-panel] [data-forge-checkout]')
const checkoutName = checkout.getByText('main', { exact: true })
await checkoutName.waitFor({ timeout: 20_000 })
await checkout.getByText('项目目录', { exact: true }).waitFor()
await checkoutName.hover()
await page.getByRole('tooltip').filter({ hasText: `项目目录:${abbreviateHomePath(project, homedir())}` }).waitFor()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('marks a draft change request ready for review and merges it with the usual method', async () => {
const { scaffold, page, root, tripwire } = await launchPanel('web-e2e-forge-panel-merge')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('The Session must stay loaded')
scaffold.ctx.forgeSession.track(session, DRAFT)
const header = page.locator('[data-forge-header]')
await expect.poll(() => header.getAttribute('aria-label'), { timeout: 20_000 }).toBe('本会话的变更请求')
await header.click()
const change = page.locator(`[data-forge-change="${DRAFT}"]`)
await change.getByRole('link', { name: 'Add the merge button', exact: true }).waitFor({ timeout: 20_000 })
// The only open change request starts expanded, so its action is in view.
await change.getByRole('button', { name: '标记为可评审', exact: true }).click()
await page.getByRole('alert').filter({ hasText: '已将 #9 标记为可评审' }).waitFor()
const merge = change.getByRole('button', { name: '合并', exact: true })
await merge.waitFor({ timeout: 20_000 })
await merge.click()
const menu = page.getByRole('menu')
await menu.getByText('将 #9 合并到 main', { exact: true }).waitFor()
await compareOrRefreshGolden(join(EXPECTED, 'merge-menu.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
await writeFile(join(root, 'state.json'), JSON.stringify({ ready: true, mergedAt: null, head: 'c'.repeat(40) }))
scaffold.ctx.forge.refresh({ repository: { forge: 'github', host: 'github.com', owner: 'octo', name: 'demo' }, number: 9 })
const stale = menu.getByRole('menuitem', { name: '提交已更新,请重新检查后再打开合并菜单', exact: true })
await stale.waitFor()
expect(await stale.isDisabled()).toBe(true)
await compareOrRefreshGolden(join(EXPECTED, 'merge-stale.expected.md'), await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), MODE)
await merge.click()
await merge.click()
await menu.getByRole('menuitem', { name: '确认合并', exact: true }).click()
await page.getByRole('alert').filter({ hasText: '已合并 #9' }).waitFor()
// Merged, the change request is no longer open, so its card collapses to the merge line.
await change.getByText(/^已合并到 main · /).waitFor({ timeout: 20_000 })
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'ready', '9', '--repo', 'github.com/octo/demo'])
expect(calls).toContainEqual([
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/9/merge', '-f', 'merge_method=squash', '-f', `sha=${'c'.repeat(40)}`,
])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
it.skipIf(MODE === 'record')('shows the local changes of the Session by scope and the changes of its change requests, expanding unchanged lines', async () => {
const { scaffold, page, root, project, tripwire } = await launchPanel('web-e2e-forge-panel-changes')
// origin/main stops at the twelve-line README; the branch adds a commit, a staged file, an edit, and an untracked file.
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => `line ${index + 1}\n`).join(''))
await git(project, 'commit', '--quiet', '-am', 'readme')
await git(project, 'update-ref', 'refs/remotes/origin/main', 'HEAD')
await git(project, 'symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main')
await mkdir(join(project, 'src'))
await writeFile(join(project, 'src', 'app.ts'), 'export const app = 1\n')
await git(project, 'add', 'src/app.ts')
await git(project, 'commit', '--quiet', '-m', 'app')
await writeFile(join(project, 'src', 'extra.ts'), 'export const extra = 1\n')
await git(project, 'add', 'src/extra.ts')
await writeFile(join(project, 'README.md'), Array.from({ length: 12 }, (_, index) => index === 9 ? 'line ten\n' : `line ${index + 1}\n`).join(''))
await writeFile(join(project, 'notes.md'), 'note\n')
const { settled } = await sendPrompt(page, scaffold)
const sessionId = await settled
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor()
const session = scaffold.ctx.sessions.get(sessionId)
if (session === undefined) throw new Error('The Session must stay loaded')
const header = page.locator('[data-forge-header]')
await header.click()
// The New Session screen inspected the checkout before these changes, which came from outside the Session.
await page.locator('[data-forge-panel]').getByRole('button', { name: '刷新状态', exact: true }).click()
const card = page.locator('[data-forge-local]')
await card.getByText('HEAD → origin/main', { exact: true }).waitFor({ timeout: 20_000 })
await card.getByRole('button', { name: '查看本会话的变更:4 个文件,新增 4 行,删除 1 行', exact: true }).click()
const local = page.locator('[data-forge-changes="local"]')
await local.getByText('与 origin/main 比较,包含未提交的修改', { exact: true }).waitFor({ timeout: 20_000 })
const files = local.locator('[data-forge-diff-file]')
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
.toEqual(['README.md', 'src/app.ts', 'src/extra.ts', 'notes.md'])
await local.locator('[data-forge-diff-file="notes.md"]').getByText('新文件 · 未提交', { exact: true }).waitFor()
const readme = local.locator('[data-forge-diff-file="README.md"]')
await readme.getByRole('button', { name: '6 行未变更', exact: true }).click()
await readme.getByText('line 1', { exact: true }).first().waitFor({ timeout: 20_000 })
await local.getByRole('button', { name: '分支', exact: true }).click()
await page.getByRole('menuitem', { name: '已暂存', exact: true }).click()
await local.getByText('暂存区与 HEAD 比较', { exact: true }).waitFor()
await expect.poll(() => files.evaluateAll(sections => sections.map(section => section.getAttribute('data-forge-diff-file'))), { timeout: 20_000 })
.toEqual(['src/extra.ts'])
// A change request bound the way a `gh pr create` result binds one.
scaffold.ctx.forgeSession.track(session, PULL)
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
await header.click()
const change = page.locator(`[data-forge-change="${PULL}"]`)
await change.getByRole('link', { name: 'Add the pull request panel', exact: true }).waitFor({ timeout: 20_000 })
await change.getByRole('button', { name: '查看 #7 的变更:新增 128 行,删除 14 行', exact: true }).click()
const view = page.locator('[data-forge-changes="change"]')
const panelFile = view.locator('[data-forge-diff-file="src/panel.ts"]')
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).waitFor({ timeout: 20_000 })
await compareOrRefreshGolden(join(EXPECTED, 'changes-view.expected.md'),
await captureStableAria(page, '[data-forge-changes="change"]', scaffold.workspaceCwd), MODE)
await panelFile.getByRole('button', { name: '6 行未变更', exact: true }).click()
await panelFile.getByText('export const n6 = 6', { exact: true }).first().waitFor({ timeout: 20_000 })
await panelFile.getByRole('button', { name: '2 行未变更', exact: true }).waitFor()
// GitHub refuses the diff of pull request 9 as too large, so its view offers the web page instead of a retry.
scaffold.ctx.forgeSession.track(session, DRAFT)
await expect.poll(() => header.getAttribute('aria-pressed'), { timeout: 20_000 }).toBe('false')
await header.click()
const draft = page.locator(`[data-forge-change="${DRAFT}"]`)
await draft.getByRole('button', { name: '展开 #9', exact: true }).click()
await draft.getByRole('button', { name: '查看 #9 的变更:新增 30 行,删除 2 行', exact: true }).click({ timeout: 20_000 })
const refused = page.locator('[data-forge-changes="change"]').filter({ hasText: 'Add the merge button' })
await refused.getByRole('status').filter({ hasText: '变更太大,无法在这里显示' }).waitFor({ timeout: 20_000 })
await refused.getByRole('button', { name: '在浏览器中查看', exact: true }).waitFor()
expect(await refused.getByRole('button', { name: '重试', exact: true }).count()).toBe(0)
const calls = (await readFile(join(root, 'calls.jsonl'), 'utf8')).trim().split('\n').map(line => JSON.parse(line) as string[])
expect(calls).toContainEqual(['pr', 'diff', '7', '--repo', 'github.com/octo/demo', '--color', 'never'])
expect(calls).toContainEqual(['pr', 'diff', '9', '--repo', 'github.com/octo/demo', '--color', 'never'])
expect(calls).toContainEqual([
'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json', `repos/octo/demo/contents/src/panel.ts?ref=${HEAD7}`,
])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
})
+1
View File
@@ -863,6 +863,7 @@ describe('web e2e: plugin manager', () => {
['@deepseek-ai/dsh-experimental-voice-input-bundle', 1],
['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1],
['@deepseek-ai/dsh-experimental-auto-review', 1],
['@deepseek-ai/dsh-experimental-forge-bundle', 1],
['@deepseek-ai/dsh-personalized-recommendations', 1],
['@deepseek-ai/dsh-experimental-inspector-profile', 1],
['@deepseek-ai/dsh-experimental-tool-worktree', 1],
+1
View File
@@ -53,6 +53,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",
+2 -2
View File
@@ -6,5 +6,5 @@
en: 41d772192075e133
zh: 5ef3da69b4de1d8a
/capability-seams-and-core-services:
en: dd5e4c757fa1197b
zh: 9c28a75dd40b7b26
en: cbe99e1985f2deae
zh: 6e524e7a644ae2d6
+8 -2
View File
@@ -40,7 +40,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"]
@@ -361,6 +363,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
@@ -515,7 +518,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
@@ -651,8 +656,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. |
+8 -2
View File
@@ -42,7 +42,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"]
@@ -363,6 +365,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
@@ -517,7 +520,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
@@ -653,8 +658,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 的作用域内服务于共享资源工具。 |
+42 -3
View File
@@ -1027,6 +1027,44 @@ 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:296`](../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; the first eight
* hexadecimal digits of the Session id's UUID, a hyphen, and four random hexadecimal digits follow it.
*/
worktreePrefix?: string
/** Largest diff, or file read to show unchanged lines, in bytes. */
maxDiffBytes?: number
/** Most untracked files a local diff or the local changes card includes. */
maxUntrackedFiles?: number
/** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */
maxCachedDiffBytes?: number
}
```
<!-- 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>
@@ -1071,7 +1109,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:205`](../packages/experimental/forge/src/types.ts)
```ts config-catalog
/** Poll cadence and fetch deadline for watched change requests. */
@@ -1117,7 +1155,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessions` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts)
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
/** Deployment settings of `forgeSession`. */
@@ -1460,7 +1498,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:21`](../packages/experimental/worktree/src/index.ts)
```ts config-catalog
/** Worktree location, generated names, executables, and subprocess resource limits. */
@@ -4931,6 +4969,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-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/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) |
+42 -3
View File
@@ -1029,6 +1029,44 @@ 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:296`](../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; the first eight
* hexadecimal digits of the Session id's UUID, a hyphen, and four random hexadecimal digits follow it.
*/
worktreePrefix?: string
/** Largest diff, or file read to show unchanged lines, in bytes. */
maxDiffBytes?: number
/** Most untracked files a local diff or the local changes card includes. */
maxUntrackedFiles?: number
/** Most UTF-8 bytes of change request diffs kept in memory for later reads at the same head commit and base branch. */
maxCachedDiffBytes?: number
}
```
<!-- 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>
@@ -1073,7 +1111,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:205`](../packages/experimental/forge/src/types.ts)
```ts config-catalog
/** Poll cadence and fetch deadline for watched change requests. */
@@ -1119,7 +1157,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-forge-session`
- `inject`: `forge` · `sessions` · `sessionProjections`
- `source`: [`packages/experimental/forge-session/src/types.ts:82`](../packages/experimental/forge-session/src/types.ts)
- `source`: [`packages/experimental/forge-session/src/types.ts:91`](../packages/experimental/forge-session/src/types.ts)
```ts config-catalog
/** Deployment settings of `forgeSession`. */
@@ -1462,7 +1500,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:21`](../packages/experimental/worktree/src/index.ts)
```ts config-catalog
/** Worktree location, generated names, executables, and subprocess resource limits. */
@@ -4933,6 +4971,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-liveshare-protocol` | — | [`packages/experimental/liveshare-protocol/src/index.ts`](../packages/experimental/liveshare-protocol/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) |
+5 -5
View File
@@ -18,8 +18,8 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../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:336`](../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:317`](../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), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`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/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`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-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../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:309`](../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` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -69,7 +69,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `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:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `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:76`](../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), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../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), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../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`) | - |
@@ -84,7 +84,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
@@ -106,7 +106,7 @@ Application listeners are outside this package matrix. Desktop Host subscribes t
| `internal/config` | - | [`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` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`plugin-market`](../packages/boot/plugin-market), `product-analytics`, `speech-to-text` |
+5 -5
View File
@@ -20,8 +20,8 @@
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:429`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), `server`, `session-controller`, [`session-telemetry`](../packages/session/session-telemetry), [`subagent`](../packages/subagent/subagent) |
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:328`](../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:336`](../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:317`](../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), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`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/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:317`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `client-ui-forge`, [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs) |
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:350`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`action-cards`](../packages/experimental/action-cards), [`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-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:389`](../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:309`](../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` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:417`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) |
@@ -71,7 +71,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:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`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:65`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `forge-session`, `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:76`](../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), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/event` | `emit` | [`packages/core/session/src/index.ts:76`](../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), [`tool-executor-ptc`](../packages/core/tool-executor-ptc), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:84`](../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`) | - |
@@ -86,7 +86,7 @@
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/events.ts:41`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/events.ts:18`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tool-executor-ptc/src/runtime-types.ts:46`](../packages/core/tool-executor-ptc/src/runtime-types.ts) | [`tool-executor-ptc`](../packages/core/tool-executor-ptc) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `tools/result` | `emit` | [`packages/core/tools/src/events.ts:49`](../packages/core/tools/src/events.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`action-cards`](../packages/experimental/action-cards), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, `forge-session`, [`subagent`](../packages/subagent/subagent), [`tool-present`](../packages/deliverables/tool-present) |
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:174`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes`, `server` |
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:44`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `remote-access`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
@@ -108,7 +108,7 @@
| `internal/config` | - | [`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` | - | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key), [`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: 4e34bb5daaeeff43
zh: e6a4fc811ae5c06b
en: 2e4c95aeb435f35e
zh: 669270a7e95ad6f2
+1
View File
@@ -14,6 +14,7 @@ Shared Session event declarations and resolved schemas appear in the [Session pe
| Record | Owner | Purpose | Payload and source |
|---|---|---|---|
| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | <code>ActionCardDecision</code> · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) |
| `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) |
@@ -14,6 +14,7 @@
| 记录 | 所属包 | 用途 | 载荷与源码 |
|---|---|---|---|
| `plugin:action-cards/decision` | `@deepseek-ai/dsh-action-cards` | Dismissals and kind-defined progress of action cards. | <code>ActionCardDecision</code> · [`packages/experimental/action-cards/src/types.ts`](../packages/experimental/action-cards/src/types.ts) |
| `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) |
+17 -4
View File
@@ -274,6 +274,7 @@ 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_stats["experimental-client-ui-stats"]
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
@@ -281,6 +282,7 @@ flowchart TD
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"]
@@ -568,9 +570,6 @@ flowchart TD
pkg_session_log_export --> pkg_session_persistence
pkg_config_editor --> pkg_app_boot
pkg_config_editor --> pkg_hmr
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_speech_to_text --> pkg_settings
pkg_experimental_translator --> pkg_credentials
pkg_experimental_translator --> pkg_launch_environment
@@ -967,6 +966,11 @@ 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_experimental_forge
pkg_experimental_client_ui_forge --> pkg_experimental_worktree
pkg_experimental_client_ui_forge --> pkg_fs
pkg_experimental_client_ui_forge --> pkg_session
pkg_experimental_client_ui_forge --> pkg_timeout
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
@@ -1124,6 +1128,13 @@ flowchart TD
pkg_experimental_auto_review --> pkg_session
pkg_experimental_auto_review --> pkg_tools
pkg_experimental_auto_review --> pkg_working_directory
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_sandbox_policy
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_forge_session --> pkg_shell
pkg_experimental_forge_session --> pkg_shell_env
pkg_experimental_forge_session --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_preset_registry
@@ -1600,6 +1611,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-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — |
@@ -1683,7 +1695,6 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
@@ -1773,6 +1784,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` | [`experimental-forge`](../packages/experimental/forge), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`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) |
@@ -1804,6 +1816,7 @@ flowchart TD
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) |
| [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+17 -4
View File
@@ -276,6 +276,7 @@ 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_stats["experimental-client-ui-stats"]
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
@@ -283,6 +284,7 @@ flowchart TD
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"]
@@ -570,9 +572,6 @@ flowchart TD
pkg_session_log_export --> pkg_session_persistence
pkg_config_editor --> pkg_app_boot
pkg_config_editor --> pkg_hmr
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_speech_to_text --> pkg_settings
pkg_experimental_translator --> pkg_credentials
pkg_experimental_translator --> pkg_launch_environment
@@ -969,6 +968,11 @@ 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_experimental_forge
pkg_experimental_client_ui_forge --> pkg_experimental_worktree
pkg_experimental_client_ui_forge --> pkg_fs
pkg_experimental_client_ui_forge --> pkg_session
pkg_experimental_client_ui_forge --> pkg_timeout
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
@@ -1126,6 +1130,13 @@ flowchart TD
pkg_experimental_auto_review --> pkg_session
pkg_experimental_auto_review --> pkg_tools
pkg_experimental_auto_review --> pkg_working_directory
pkg_experimental_forge_session --> pkg_experimental_forge
pkg_experimental_forge_session --> pkg_sandbox_policy
pkg_experimental_forge_session --> pkg_session
pkg_experimental_forge_session --> pkg_session_projection
pkg_experimental_forge_session --> pkg_shell
pkg_experimental_forge_session --> pkg_shell_env
pkg_experimental_forge_session --> pkg_tools
pkg_webhook --> pkg_agent
pkg_webhook --> pkg_agent_default_model
pkg_webhook --> pkg_agent_preset_registry
@@ -1602,6 +1613,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-liveshare-protocol`](../packages/experimental/liveshare-protocol) | `experimental` | — |
@@ -1685,7 +1697,6 @@ flowchart TD
| [`spill-local`](../packages/spill/spill-local) | `spill` | [`spill`](../packages/spill/spill) |
| [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence) |
| [`config-editor`](../packages/boot/config-editor) | `boot` | [`app-boot`](../packages/boot/app-boot), [`hmr`](../packages/boot/hmr) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | `experimental` | [`settings`](../packages/settings/settings) |
| [`experimental-translator`](../packages/experimental/translator) | `experimental` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`timeout`](../packages/util/timeout), [`util-values`](../packages/util/values) |
| [`host-product-telemetry-otel`](../packages/host/product-telemetry-otel) | `host` | [`otel`](../packages/telemetry/otel) |
@@ -1775,6 +1786,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` | [`experimental-forge`](../packages/experimental/forge), [`experimental-worktree`](../packages/experimental/worktree), [`fs`](../packages/fs/fs), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
| [`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) |
@@ -1806,6 +1818,7 @@ flowchart TD
| [`api-settings-controller`](../packages/api/settings-controller) | `api` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`credentials`](../packages/credentials/credentials), [`native-command`](../packages/util/native-command), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`typert-protocol`](../packages/typert/protocol) |
| [`web-app`](../packages/bundle/web-app) | `bundle` | [`shell-env`](../packages/shell/shell-env) |
| [`experimental-auto-review`](../packages/experimental/auto-review) | `experimental` | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`working-directory`](../packages/session/working-directory) |
| [`experimental-forge-session`](../packages/experimental/forge-session) | `experimental` | [`experimental-forge`](../packages/experimental/forge), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`tools`](../packages/core/tools) |
| [`webhook`](../packages/experimental/webhook) | `experimental` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`app-boot`](../packages/boot/app-boot), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`brand`](../packages/util/brand), [`typert-protocol`](../packages/typert/protocol) |
+10 -4
View File
@@ -3,14 +3,20 @@
# 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
/forges/status-polling:
en: 26ccfa2e342e3e67
zh: fe6cc2a622757c48
/forges/change-request-actions:
en: 00e4490e416099d2
zh: 5724d08b6ae230f7
/forges/session-bindings:
en: 036a714f103fafd8
zh: aa11325be880025d
en: 4d65f16562d67181
zh: 676e411b32bc7615
/forges/panel:
en: 7a9044e9b9f69545
zh: 5d25017909e3bb28
+708 -19
View File
@@ -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
@@ -58,6 +58,28 @@ 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>
/** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/** Read the changes of a change request as the host computes them, as one unified patch with `a/` and `b/` path prefixes. */
diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
/** Read the text of `path` at `commit` in the change request's repository. */
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/** Build the web page of `path` at `commit` in the change request's repository. */
fileUrl(ref: ChangeRequestRef, commit: string, path: string): string
}
```
```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
}
```
@@ -86,6 +108,10 @@ interface ChangeRequestCheck {
readonly url: string | null
/** Workflow that produced the check, or null when the host reports none. */
readonly workflow: string | null
/** ISO 8601 time the check started, or null before it starts and when the host reports none. */
readonly startedAt: string | null
/** ISO 8601 time the check completed, or null while it runs and when the host reports none. */
readonly completedAt: string | null
}
```
@@ -104,16 +130,6 @@ type Mergeability = 'mergeable' | 'conflicting' | 'unknown'
type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none'
```
```ts type-equiv
/** Why a provider could not read a change request. */
interface ForgeFailure {
/** `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; `unavailable`: any other failure. */
readonly code: 'not-found' | 'unauthenticated' | 'unavailable'
/** Human-readable cause. */
readonly message: string
}
```
```ts type-equiv
/** Status of one change request at the time its provider read it. */
interface ChangeRequestSnapshot {
@@ -135,8 +151,19 @@ 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
/**
* Whether the host would merge the change request now for the signed-in
* user: open, not a draft, and meeting the base branch's merge rules.
*/
readonly mergeReady: boolean
/** Merge methods the repository accepts, the signed-in user's usual one first. */
readonly mergeMethods: readonly MergeMethod[]
/** Aggregate review outcome. */
readonly reviewDecision: ReviewDecision
/** Checks on the head commit in host order. */
@@ -149,6 +176,22 @@ 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
}
```
```ts type-equiv
/** Why a provider could not read a change request. */
interface ForgeFailure {
/**
* `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected;
* `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails
* the same way; `unavailable`: any other failure.
*/
readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'unavailable'
/** Human-readable cause. */
readonly message: string
}
```
@@ -176,25 +219,56 @@ type ChangeRequestUpdate = {
type ChangeRequestListener = (update: ChangeRequestUpdate) => void
```
## Change request actions
`ctx.forge.markReady` marks a draft ready for review, and `ctx.forge.merge` merges a change request only while its head is still the commit the user confirmed. Both resolve to a `ForgeActionFailure` instead of throwing when the host refuses, and poll a watched change request at once afterwards. A snapshot's `mergeReady` and `mergeMethods` tell a consumer when to offer a merge and with which methods; the [GitHub provider README](../../packages/experimental/forge-github/README.md#use-this-package) owns how GitHub answers both.
```ts type-equiv
/** A merge the user confirmed for the head commit they saw. */
interface MergeRequest {
/** How the commits enter the base branch. */
readonly method: MergeMethod
/** Commit object name the head must still have; the merge is refused once the head moves. */
readonly headSha: string
}
```
```ts type-equiv
/** Why a provider could not change a change request. */
interface ForgeActionFailure {
/**
* `unauthenticated`: credentials are missing or rejected; `stale`: the head
* moved past the confirmed commit; `failed`: the host refused or could not be reached.
*/
readonly code: 'unauthenticated' | 'stale' | 'failed'
/** Human-readable cause, usually the host's own message. */
readonly message: string
}
```
## 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, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results trigger head-branch discovery through `ctx.forge`; the panel also reports the branch its checkout tracks for the same discovery. 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
/**
* How a change request became bound to a Session: `created` when a
* `gh pr create` result printed its URL, `discovered` when a lookup after a
* `git push` found it for the pushed branch, `tracked` when a caller bound it
* by URL.
* `gh pr create` result printed its URL, `referenced` when a `gh pr view`,
* `gh pr edit`, or `gh pr checkout` call named it or its result printed its
* own URL, `discovered` when a lookup found it for a branch the Session pushed
* or works on, `tracked` when a caller bound it by URL.
*/
type BindingOrigin = 'created' | 'discovered' | 'tracked'
type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
```
```ts type-equiv
/** Payload of the `plugin:forge-session/binding` record. */
interface BindingRecord {
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */
readonly action: 'track' | 'dismiss' | 'discover'
/**
* `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup,
* and `reference` binds a CLI selector lookup without restoring dismissed entries.
*/
readonly action: 'track' | 'dismiss' | 'discover' | 'reference'
/** Web URL of the change request. */
readonly url: string
}
@@ -222,6 +296,332 @@ 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, mark a bound draft ready or merge a bound change request, 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. When a message enters a Session's inbox while its Agent is idle, the panel holds the message's turn with maintenance of the [Agent](../../packages/core/agent/README.md), started before the delivery wakes the Agent: it creates the worktree of an open request through `ctx.worktrees` and, after a failed or stopped attempt, waits for another attempt or the request's withdrawal; for a Session that works in its created worktree it checks that worktree and, when it is gone, waits until it is restored or left. The panel state names what a message waits for and the last failed attempt. The first step proposal creates the worktree for a message no hold took. 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
/** Whether the checkout is a linked worktree of another checkout's repository. */
readonly linked: boolean
/** Abbreviated commit HEAD names, or null while the checked-out branch has no commit. */
readonly commit: 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 }
/** Restoration started; the checkout stays unavailable until restoration completes. */
| { readonly action: 'restoring' }
/** The recorded worktree was completely restored. */
| { readonly action: 'restored' }
```
```ts type-equiv
/** One open worktree request. */
interface ForgeWorktreeRequest {
/** Base branch, or null for HEAD of the source checkout. */
readonly from: string | null
}
```
```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 its turn starts. Only
* such a creation can be stopped, after which the message keeps waiting; a
* creation at the first step serves a message the Session already holds.
*/
readonly held: boolean
}
```
```ts type-equiv
/** One running restoration of the removed worktree a Session works in. */
interface ForgeWorktreeRestoration {
/** Running step: `prepare` registers the checkout again, creating a missing branch, then `checkout` writes the files. */
readonly phase: 'prepare' | 'checkout'
}
```
```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
/**
* What a message waits for before its turn starts: the creation of the
* requested worktree, or the restoration or leaving of the removed worktree
* the Session works in.
*/
type ForgeWorktreeWaiting = 'creation' | 'restoration'
```
```ts type-equiv
/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */
interface ForgeMissingWorktree {
/** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */
readonly branchExists: boolean | null
}
```
```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 last failed attempt of the creation a message waits for, or null. It
* clears when another attempt starts, when the request changes or is
* withdrawn, and when the wait ends.
*/
readonly failure: ForgeWorktreeFailure | null
/** The created worktree the Session works in when its directory is gone, or null. */
readonly missing: ForgeMissingWorktree | null
/** Running restoration of the removed worktree, or null. */
readonly restoration: ForgeWorktreeRestoration | null
/** What a message of the Session waits for before its turn starts, or null while none waits. */
readonly waiting: ForgeWorktreeWaiting | 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
/**
* Files the `branch` scope changes, or null before the first inspection,
* outside a checkout, and without a fork point.
*/
readonly local: ForgeLocalChanges | null
}
```
```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[]
}
```
The changes view reads through the same Remote. `localDiff` reads one `ForgeLocalScope` of the Session's checkout, and `changeRequestDiff` reads a bound change request at the head of its latest status through the provider's `diff`; both return files that the Host parsed into `ForgeDiffFile` hunks. `localText` and `changeRequestText` read the new side of one file, which supplies the unchanged lines between hunks. Each inspection also sizes the `branch` scope as `ForgePanelState.local` for the panel's local changes card.
```ts type-equiv
/** Which local changes a diff covers; each reads the Session's checkout through Git. */
type ForgeLocalScope =
/**
* The working tree and untracked files against the fork point: the base commit of the worktree the
* Session created, else the merge base of HEAD with the default branch of the code host remote.
*/
| { readonly kind: 'branch' }
/** The working tree and untracked files against HEAD. */
| { readonly kind: 'uncommitted' }
/** The working tree and untracked files against the index. */
| { readonly kind: 'unstaged' }
/** The index against HEAD. */
| { readonly kind: 'staged' }
```
```ts type-equiv
/** One hunk of a file diff. */
interface ForgeDiffHunk {
/** First old line the hunk covers, or the old line it comes before when it covers none. */
readonly oldStart: number
/** Old lines the hunk covers. */
readonly oldLines: number
/** First new line the hunk covers, or the new line it comes before when it covers none. */
readonly newStart: number
/** New lines the hunk covers. */
readonly newLines: number
/** Lines in patch form: a leading space for context, `+` for added, `-` for deleted, and `\` for a missing final newline. */
readonly lines: readonly string[]
}
```
```ts type-equiv
/** How one file changed. */
type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed'
```
```ts type-equiv
/** One changed file of a diff. */
interface ForgeDiffFile {
/** Path relative to the repository root after the change, or before it for a deleted file. */
readonly path: string
/** Path before a rename, else null. */
readonly previousPath: string | null
/** How the file changed. */
readonly status: ForgeDiffStatus
/** Whether the content is binary, which carries no hunks. */
readonly binary: boolean
/** Whether an untracked file exceeds `maxDiffBytes`, so it carries no hunks. */
readonly oversized: boolean
/** Whether Git does not track the file yet; only local diffs list such files. */
readonly untracked: boolean
/** Lines added. */
readonly additions: number
/** Lines deleted. */
readonly deletions: number
/** Hunks in file order; none for a binary file or a change of mode or name only. */
readonly hunks: readonly ForgeDiffHunk[]
}
```
```ts type-equiv
/** Local changes of the Session's checkout in one scope. */
interface ForgeLocalDiff {
/** Ref or abbreviated commit the `branch` scope compares with, such as `origin/master`; null for other scopes. */
readonly base: string | null
/** Changed files in Git's path order, untracked files last. */
readonly files: readonly ForgeDiffFile[]
/** Untracked files left out beyond `maxUntrackedFiles`. */
readonly omitted: number
}
```
```ts type-equiv
/** Changes of a change request as its code host computes them. */
interface ForgeChangeRequestDiff {
/** Head commit the diff describes. */
readonly headSha: string
/** Changed files in the host's order. */
readonly files: readonly ForgeDiffFile[]
/**
* Commits the checkout's branch has beyond the head when that branch is the change request's head
* branch and the head commit is present locally; null otherwise.
*/
readonly localAhead: number | null
/** Web page of each changed file at the head commit, by path. */
readonly fileUrls: Readonly<Record<string, string>>
}
```
```ts type-equiv
/** One file the `branch` scope changes, without its hunks. */
interface ForgeChangedFile {
/** Path relative to the repository root after the change. */
readonly path: string
/** Lines added, or 0 for a binary file. */
readonly additions: number
/** Lines deleted, or 0 for a binary file. */
readonly deletions: number
/** Whether the content is binary. */
readonly binary: boolean
}
```
```ts type-equiv
/** Size of the `branch` scope, which the panel's local changes card shows. */
interface ForgeLocalChanges {
/** Ref or abbreviated commit the scope compares with. */
readonly base: string
/** Changed files in Git's path order, untracked files last, at most `maxUntrackedFiles` of those. */
readonly files: readonly ForgeChangedFile[]
}
```
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -280,6 +680,65 @@ 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>
/**
* Mark a draft change request ready for review, then poll it at once when it is watched.
* @param ref - change request served by a registered provider.
* @param signal - cancellation of the request.
* @returns undefined once the host accepted the change, or why it did not.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/**
* Merge a change request while its head is still `request.headSha`, then
* poll it at once when it is watched.
* @param ref - change request served by a registered provider.
* @param request - merge method and the head commit the user confirmed.
* @param signal - cancellation of the request.
* @returns undefined once the host merged it, or why it did not.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/**
* Read the changes of a change request as its host computes them.
* @param ref - change request served by a registered provider.
* @param signal - cancellation of the read.
* @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
/**
* Read the text of one file at a commit of a change request's repository.
* @param ref - change request served by a registered provider.
* @param commit - commit object name.
* @param path - path relative to the repository root.
* @param signal - cancellation of the read.
* @returns the text, or why the host did not provide it.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/**
* Build the web page of one file at a commit of a change request's repository.
* @param ref - change request served by a registered provider.
* @param commit - commit object name.
* @param path - path relative to the repository root.
* @returns the provider's URL.
* @throws when no provider serves the change request's forge.
*/
fileUrl(ref: ChangeRequestRef, commit: string, path: string): string
/**
* Watch one change request as an effect on the calling fiber. The first
* watcher starts polling at once; listeners receive every later result,
@@ -291,6 +750,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 is followed immediately by another fetch; 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.
@@ -301,11 +768,221 @@ 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 while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is 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.
* @returns settles once the inspection and the sign-in probe have ended; poll results reach the panel stream later.
*/
@Remote async refresh(session: Session, signal: AbortSignal): Promise<void>
/**
* Read the changes one scope covers in the checkout of the Session's
* current directory, with Git confined to the Session's read-only sandbox.
* @param session - Session whose checkout is read.
* @param scope - which changes to read.
* @param signal - request cancellation.
* @returns the files, the base of the `branch` scope, and the untracked files left out.
* @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch`
* scope of a checkout without one; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails.
*/
@Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise<ForgeLocalDiff>
/**
* Read the new side of one changed file in a scope, which supplies the
* unchanged lines between its hunks: the index for `staged`, else the working tree.
* @param session - Session whose checkout is read.
* @param scope - the scope the diff was read in.
* @param path - path relative to the checkout's top-level directory, as the diff names it.
* @param signal - request cancellation.
* @returns the text.
* @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`;
* an error for a path outside the checkout, binary content, or a failed read.
*/
@Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise<string>
/**
* Read the changes of a change request of a Session as its code host
* computes them, at the head commit of its latest status. A patch already
* read at the same head commit and base branch is reused from memory, for
* any Session, until `maxCachedDiffBytes` evicts it.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param signal - request cancellation.
* @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit;
* `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise.
*/
@Remote async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise<ForgeChangeRequestDiff>
/**
* Read one file of a change request's repository at a commit, which
* supplies the unchanged lines between the hunks of its diff.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param commit - commit object name, the head commit of the diff.
* @param path - path relative to the repository root.
* @param signal - request cancellation.
* @returns the text.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit;
* `forgePanel/diff-failed` when the host does not provide the file otherwise.
*/
@Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise<string>
/**
* Mark a draft change request of a Session ready for review; the change
* request is polled again once the host answers.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param signal - request cancellation.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached.
*/
@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise<void>
/**
* Merge a change request of a Session while its head is still `headSha`;
* the change request is polled again once the host answers.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param method - how the commits enter the base branch.
* @param headSha - head commit the user confirmed.
* @param signal - request cancellation.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's
* reason when the host refuses otherwise or cannot be reached.
*/
@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise<void>
/**
* Ask for a new worktree before the Session's first turn, replacing an open
* request and forgetting a failed attempt. While the next message waits in
* the inbox, or else at the first step proposal, the panel creates a branch
* from `from`, or from HEAD of the current checkout when it is null, and
* enters its checkout. A message that already waits after a failed or
* stopped attempt keeps waiting for {@link ForgePanelController.retryWorktree}.
* @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,
* which lets a waiting message go on in the current checkout; without a
* request the Session is unchanged. Withdrawing needs no worktree service.
* @param session - Session without a started turn.
* @param signal - request cancellation.
* @throws `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 a message of the Session waits for; the message keeps
* waiting with the request still 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>
/**
* Make another attempt, with a new branch name, at the worktree a message
* of the Session waits for after a failed or stopped attempt.
* @param session - Session whose message waits.
* @param signal - request cancellation.
* @throws `forgePanel/not-waiting` when no message waits or an attempt is running.
*/
@Remote async retryWorktree(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,
* inside the wait of a message that waits for the restoration, which then
* goes on; 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, inside the wait of a message that waits for the removed
* worktree, which then goes on.
* @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`
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
```ts cordis-catalog
/**
@@ -325,6 +1002,18 @@ changeRequests(session: Session): SessionChangeRequest[]
*/
track(session: Session, url: string): void
/**
* Look up open change requests whose head is a branch the Session works on,
* and record the unbound ones as discovered, as after a push of that branch.
* A remote that no provider recognizes changes nothing; a failed lookup is
* logged and changes nothing.
* @param session - Session that receives the bindings.
* @param remote - URL of the remote that holds the branch.
* @param branch - branch name on that remote.
* @returns resolves once the lookup has recorded its bindings or failed.
*/
discoverBranch(session: Session, remote: string, branch: string): Promise<void>
/**
* Hide a visible binding. Appends a `dismiss` record; a URL that is not
* visibly bound leaves the log unchanged. Discovery never restores a
+708 -19
View File
@@ -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);除此之外,它们只能通过显式组合加载。
## 提供方与变更请求
@@ -58,6 +58,28 @@ 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>
/** Mark a draft change request ready for review, resolving to undefined once the host accepted it. */
markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/** Merge a change request while its head is still `request.headSha`, resolving to undefined once merged. */
merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/** Read the changes of a change request as the host computes them, as one unified patch with `a/` and `b/` path prefixes. */
diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
/** Read the text of `path` at `commit` in the change request's repository. */
fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/** Build the web page of `path` at `commit` in the change request's repository. */
fileUrl(ref: ChangeRequestRef, commit: string, path: string): string
}
```
```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
}
```
@@ -86,6 +108,10 @@ interface ChangeRequestCheck {
readonly url: string | null
/** Workflow that produced the check, or null when the host reports none. */
readonly workflow: string | null
/** ISO 8601 time the check started, or null before it starts and when the host reports none. */
readonly startedAt: string | null
/** ISO 8601 time the check completed, or null while it runs and when the host reports none. */
readonly completedAt: string | null
}
```
@@ -104,16 +130,6 @@ type Mergeability = 'mergeable' | 'conflicting' | 'unknown'
type ReviewDecision = 'approved' | 'changes-requested' | 'review-required' | 'none'
```
```ts type-equiv
/** Why a provider could not read a change request. */
interface ForgeFailure {
/** `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected; `unavailable`: any other failure. */
readonly code: 'not-found' | 'unauthenticated' | 'unavailable'
/** Human-readable cause. */
readonly message: string
}
```
```ts type-equiv
/** Status of one change request at the time its provider read it. */
interface ChangeRequestSnapshot {
@@ -135,8 +151,19 @@ 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
/**
* Whether the host would merge the change request now for the signed-in
* user: open, not a draft, and meeting the base branch's merge rules.
*/
readonly mergeReady: boolean
/** Merge methods the repository accepts, the signed-in user's usual one first. */
readonly mergeMethods: readonly MergeMethod[]
/** Aggregate review outcome. */
readonly reviewDecision: ReviewDecision
/** Checks on the head commit in host order. */
@@ -149,6 +176,22 @@ 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
}
```
```ts type-equiv
/** Why a provider could not read a change request. */
interface ForgeFailure {
/**
* `not-found`: the host does not expose it; `unauthenticated`: credentials are missing or rejected;
* `too-large`: the host or the provider refuses an answer over its size limit, so reading again fails
* the same way; `unavailable`: any other failure.
*/
readonly code: 'not-found' | 'unauthenticated' | 'too-large' | 'unavailable'
/** Human-readable cause. */
readonly message: string
}
```
@@ -176,25 +219,56 @@ type ChangeRequestUpdate = {
type ChangeRequestListener = (update: ChangeRequestUpdate) => void
```
## 变更请求操作
`ctx.forge.markReady` 把草稿标记为可评审,`ctx.forge.merge` 只在 head 仍是用户确认的提交时合并变更请求。主机拒绝时,两者都 resolve 为 `ForgeActionFailure` 而不是抛错,并在结束后立即轮询被监听的变更请求。快照的 `mergeReady` 与 `mergeMethods` 告诉消费方何时提供合并、可用哪些合并方式;GitHub 如何给出这两项由 [GitHub 提供方 README](../../packages/experimental/forge-github/README.zh.md#use-this-package) 说明。
```ts type-equiv
/** A merge the user confirmed for the head commit they saw. */
interface MergeRequest {
/** How the commits enter the base branch. */
readonly method: MergeMethod
/** Commit object name the head must still have; the merge is refused once the head moves. */
readonly headSha: string
}
```
```ts type-equiv
/** Why a provider could not change a change request. */
interface ForgeActionFailure {
/**
* `unauthenticated`: credentials are missing or rejected; `stale`: the head
* moved past the confirmed commit; `failed`: the host refused or could not be reached.
*/
readonly code: 'unauthenticated' | 'stale' | 'failed'
/** Human-readable cause, usually the host's own message. */
readonly message: string
}
```
## 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,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`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
/**
* How a change request became bound to a Session: `created` when a
* `gh pr create` result printed its URL, `discovered` when a lookup after a
* `git push` found it for the pushed branch, `tracked` when a caller bound it
* by URL.
* `gh pr create` result printed its URL, `referenced` when a `gh pr view`,
* `gh pr edit`, or `gh pr checkout` call named it or its result printed its
* own URL, `discovered` when a lookup found it for a branch the Session pushed
* or works on, `tracked` when a caller bound it by URL.
*/
type BindingOrigin = 'created' | 'discovered' | 'tracked'
type BindingOrigin = 'created' | 'referenced' | 'discovered' | 'tracked'
```
```ts type-equiv
/** Payload of the `plugin:forge-session/binding` record. */
interface BindingRecord {
/** `track` binds or restores, `dismiss` hides, `discover` binds a change request found for a pushed branch. */
readonly action: 'track' | 'dismiss' | 'discover'
/**
* `track` binds or restores, `dismiss` hides, `discover` binds a branch lookup,
* and `reference` binds a CLI selector lookup without restoring dismissed entries.
*/
readonly action: 'track' | 'dismiss' | 'discover' | 'reference'
/** Web URL of the change request. */
readonly url: string
}
@@ -222,6 +296,332 @@ 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` 记录。消息在 Session 的 Agent 空闲时进入其收件箱,面板会在这次投递唤醒 [Agent](../../packages/core/agent/README.zh.md) 之前启动它的维护任务,暂留这条消息的这一轮:对未完成的请求,它通过 `ctx.worktrees` 创建 worktree,尝试失败或被停止后等待下一次尝试或请求被撤回;对在所创建 worktree 中工作的 Session,它检查该 worktree,已被删除时等待它被恢复或离开。面板状态说明消息在等待什么,以及最近一次失败的尝试。对于没有被暂留的消息,由第一个步骤提议创建 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
/** Whether the checkout is a linked worktree of another checkout's repository. */
readonly linked: boolean
/** Abbreviated commit HEAD names, or null while the checked-out branch has no commit. */
readonly commit: 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 }
/** Restoration started; the checkout stays unavailable until restoration completes. */
| { readonly action: 'restoring' }
/** The recorded worktree was completely restored. */
| { readonly action: 'restored' }
```
```ts type-equiv
/** One open worktree request. */
interface ForgeWorktreeRequest {
/** Base branch, or null for HEAD of the source checkout. */
readonly from: string | null
}
```
```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 its turn starts. Only
* such a creation can be stopped, after which the message keeps waiting; a
* creation at the first step serves a message the Session already holds.
*/
readonly held: boolean
}
```
```ts type-equiv
/** One running restoration of the removed worktree a Session works in. */
interface ForgeWorktreeRestoration {
/** Running step: `prepare` registers the checkout again, creating a missing branch, then `checkout` writes the files. */
readonly phase: 'prepare' | 'checkout'
}
```
```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
/**
* What a message waits for before its turn starts: the creation of the
* requested worktree, or the restoration or leaving of the removed worktree
* the Session works in.
*/
type ForgeWorktreeWaiting = 'creation' | 'restoration'
```
```ts type-equiv
/** The recorded worktree whose directory is missing, invalid, or incompletely restored. */
interface ForgeMissingWorktree {
/** Whether its branch exists, or null when an incomplete restoration cannot inspect it. */
readonly branchExists: boolean | null
}
```
```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 last failed attempt of the creation a message waits for, or null. It
* clears when another attempt starts, when the request changes or is
* withdrawn, and when the wait ends.
*/
readonly failure: ForgeWorktreeFailure | null
/** The created worktree the Session works in when its directory is gone, or null. */
readonly missing: ForgeMissingWorktree | null
/** Running restoration of the removed worktree, or null. */
readonly restoration: ForgeWorktreeRestoration | null
/** What a message of the Session waits for before its turn starts, or null while none waits. */
readonly waiting: ForgeWorktreeWaiting | 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
/**
* Files the `branch` scope changes, or null before the first inspection,
* outside a checkout, and without a fork point.
*/
readonly local: ForgeLocalChanges | null
}
```
```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[]
}
```
变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用。
```ts type-equiv
/** Which local changes a diff covers; each reads the Session's checkout through Git. */
type ForgeLocalScope =
/**
* The working tree and untracked files against the fork point: the base commit of the worktree the
* Session created, else the merge base of HEAD with the default branch of the code host remote.
*/
| { readonly kind: 'branch' }
/** The working tree and untracked files against HEAD. */
| { readonly kind: 'uncommitted' }
/** The working tree and untracked files against the index. */
| { readonly kind: 'unstaged' }
/** The index against HEAD. */
| { readonly kind: 'staged' }
```
```ts type-equiv
/** One hunk of a file diff. */
interface ForgeDiffHunk {
/** First old line the hunk covers, or the old line it comes before when it covers none. */
readonly oldStart: number
/** Old lines the hunk covers. */
readonly oldLines: number
/** First new line the hunk covers, or the new line it comes before when it covers none. */
readonly newStart: number
/** New lines the hunk covers. */
readonly newLines: number
/** Lines in patch form: a leading space for context, `+` for added, `-` for deleted, and `\` for a missing final newline. */
readonly lines: readonly string[]
}
```
```ts type-equiv
/** How one file changed. */
type ForgeDiffStatus = 'added' | 'deleted' | 'modified' | 'renamed'
```
```ts type-equiv
/** One changed file of a diff. */
interface ForgeDiffFile {
/** Path relative to the repository root after the change, or before it for a deleted file. */
readonly path: string
/** Path before a rename, else null. */
readonly previousPath: string | null
/** How the file changed. */
readonly status: ForgeDiffStatus
/** Whether the content is binary, which carries no hunks. */
readonly binary: boolean
/** Whether an untracked file exceeds `maxDiffBytes`, so it carries no hunks. */
readonly oversized: boolean
/** Whether Git does not track the file yet; only local diffs list such files. */
readonly untracked: boolean
/** Lines added. */
readonly additions: number
/** Lines deleted. */
readonly deletions: number
/** Hunks in file order; none for a binary file or a change of mode or name only. */
readonly hunks: readonly ForgeDiffHunk[]
}
```
```ts type-equiv
/** Local changes of the Session's checkout in one scope. */
interface ForgeLocalDiff {
/** Ref or abbreviated commit the `branch` scope compares with, such as `origin/master`; null for other scopes. */
readonly base: string | null
/** Changed files in Git's path order, untracked files last. */
readonly files: readonly ForgeDiffFile[]
/** Untracked files left out beyond `maxUntrackedFiles`. */
readonly omitted: number
}
```
```ts type-equiv
/** Changes of a change request as its code host computes them. */
interface ForgeChangeRequestDiff {
/** Head commit the diff describes. */
readonly headSha: string
/** Changed files in the host's order. */
readonly files: readonly ForgeDiffFile[]
/**
* Commits the checkout's branch has beyond the head when that branch is the change request's head
* branch and the head commit is present locally; null otherwise.
*/
readonly localAhead: number | null
/** Web page of each changed file at the head commit, by path. */
readonly fileUrls: Readonly<Record<string, string>>
}
```
```ts type-equiv
/** One file the `branch` scope changes, without its hunks. */
interface ForgeChangedFile {
/** Path relative to the repository root after the change. */
readonly path: string
/** Lines added, or 0 for a binary file. */
readonly additions: number
/** Lines deleted, or 0 for a binary file. */
readonly deletions: number
/** Whether the content is binary. */
readonly binary: boolean
}
```
```ts type-equiv
/** Size of the `branch` scope, which the panel's local changes card shows. */
interface ForgeLocalChanges {
/** Ref or abbreviated commit the scope compares with. */
readonly base: string
/** Changed files in Git's path order, untracked files last, at most `maxUntrackedFiles` of those. */
readonly files: readonly ForgeChangedFile[]
}
```
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -280,6 +680,65 @@ 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>
/**
* Mark a draft change request ready for review, then poll it at once when it is watched.
* @param ref - change request served by a registered provider.
* @param signal - cancellation of the request.
* @returns undefined once the host accepted the change, or why it did not.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async markReady(ref: ChangeRequestRef, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/**
* Merge a change request while its head is still `request.headSha`, then
* poll it at once when it is watched.
* @param ref - change request served by a registered provider.
* @param request - merge method and the head commit the user confirmed.
* @param signal - cancellation of the request.
* @returns undefined once the host merged it, or why it did not.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async merge(ref: ChangeRequestRef, request: MergeRequest, signal: AbortSignal): Promise<ForgeActionFailure | undefined>
/**
* Read the changes of a change request as its host computes them.
* @param ref - change request served by a registered provider.
* @param signal - cancellation of the read.
* @returns one unified patch with `a/` and `b/` path prefixes, or why the host did not provide it.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async diff(ref: ChangeRequestRef, signal: AbortSignal): Promise<string | ForgeFailure>
/**
* Read the text of one file at a commit of a change request's repository.
* @param ref - change request served by a registered provider.
* @param commit - commit object name.
* @param path - path relative to the repository root.
* @param signal - cancellation of the read.
* @returns the text, or why the host did not provide it.
* @throws when no provider serves the change request's forge, or with `signal.reason` when it aborts.
*/
async fileText(ref: ChangeRequestRef, commit: string, path: string, signal: AbortSignal): Promise<string | ForgeFailure>
/**
* Build the web page of one file at a commit of a change request's repository.
* @param ref - change request served by a registered provider.
* @param commit - commit object name.
* @param path - path relative to the repository root.
* @returns the provider's URL.
* @throws when no provider serves the change request's forge.
*/
fileUrl(ref: ChangeRequestRef, commit: string, path: string): string
/**
* Watch one change request as an effect on the calling fiber. The first
* watcher starts polling at once; listeners receive every later result,
@@ -291,6 +750,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 is followed immediately by another fetch; 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.
@@ -301,11 +768,221 @@ 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 while the Session's first message waits in the inbox, or at the first step proposal of a message no wait took. After a failed or stopped attempt the message keeps waiting for another attempt or for the request to be withdrawn. A message also waits while the worktree the Session works in is gone, until that worktree is 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.
* @returns settles once the inspection and the sign-in probe have ended; poll results reach the panel stream later.
*/
@Remote async refresh(session: Session, signal: AbortSignal): Promise<void>
/**
* Read the changes one scope covers in the checkout of the Session's
* current directory, with Git confined to the Session's read-only sandbox.
* @param session - Session whose checkout is read.
* @param scope - which changes to read.
* @param signal - request cancellation.
* @returns the files, the base of the `branch` scope, and the untracked files left out.
* @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/no-fork-point` for the `branch`
* scope of a checkout without one; `forgePanel/diff-too-large` over `maxDiffBytes`; the Git error when Git fails.
*/
@Remote async localDiff(session: Session, scope: ForgeLocalScope, signal: AbortSignal): Promise<ForgeLocalDiff>
/**
* Read the new side of one changed file in a scope, which supplies the
* unchanged lines between its hunks: the index for `staged`, else the working tree.
* @param session - Session whose checkout is read.
* @param scope - the scope the diff was read in.
* @param path - path relative to the checkout's top-level directory, as the diff names it.
* @param signal - request cancellation.
* @returns the text.
* @throws `forgePanel/not-a-checkout` outside a checkout; `forgePanel/diff-too-large` over `maxDiffBytes`;
* an error for a path outside the checkout, binary content, or a failed read.
*/
@Remote async localText(session: Session, scope: ForgeLocalScope, path: string, signal: AbortSignal): Promise<string>
/**
* Read the changes of a change request of a Session as its code host
* computes them, at the head commit of its latest status. A patch already
* read at the same head commit and base branch is reused from memory, for
* any Session, until `maxCachedDiffBytes` evicts it.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param signal - request cancellation.
* @returns the files, the head commit, each file's web page, and the commits the checkout has beyond the head.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/diff-too-large` when the host or its provider refuses the diff over a size limit;
* `forgePanel/diff-failed` before its status was read and when the host does not provide the diff otherwise.
*/
@Remote async changeRequestDiff(session: Session, url: string, signal: AbortSignal): Promise<ForgeChangeRequestDiff>
/**
* Read one file of a change request's repository at a commit, which
* supplies the unchanged lines between the hunks of its diff.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param commit - commit object name, the head commit of the diff.
* @param path - path relative to the repository root.
* @param signal - request cancellation.
* @returns the text.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/diff-too-large` when the host or its provider refuses the file over a size limit;
* `forgePanel/diff-failed` when the host does not provide the file otherwise.
*/
@Remote async changeRequestText(session: Session, url: string, commit: string, path: string, signal: AbortSignal): Promise<string>
/**
* Mark a draft change request of a Session ready for review; the change
* request is polled again once the host answers.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param signal - request cancellation.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/action-failed` with the host's reason when the host refuses or cannot be reached.
*/
@Remote async markReady(session: Session, url: string, signal: AbortSignal): Promise<void>
/**
* Merge a change request of a Session while its head is still `headSha`;
* the change request is polled again once the host answers.
* @param session - Session the change request is bound to.
* @param url - binding URL of the change request.
* @param method - how the commits enter the base branch.
* @param headSha - head commit the user confirmed.
* @param signal - request cancellation.
* @throws `forgePanel/not-bound` unless the URL is a visible binding of the Session that a provider serves;
* `forgePanel/stale-head` when the head moved past `headSha`; `forgePanel/action-failed` with the host's
* reason when the host refuses otherwise or cannot be reached.
*/
@Remote async merge(session: Session, url: string, method: MergeMethod, headSha: string, signal: AbortSignal): Promise<void>
/**
* Ask for a new worktree before the Session's first turn, replacing an open
* request and forgetting a failed attempt. While the next message waits in
* the inbox, or else at the first step proposal, the panel creates a branch
* from `from`, or from HEAD of the current checkout when it is null, and
* enters its checkout. A message that already waits after a failed or
* stopped attempt keeps waiting for {@link ForgePanelController.retryWorktree}.
* @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,
* which lets a waiting message go on in the current checkout; without a
* request the Session is unchanged. Withdrawing needs no worktree service.
* @param session - Session without a started turn.
* @param signal - request cancellation.
* @throws `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 a message of the Session waits for; the message keeps
* waiting with the request still 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>
/**
* Make another attempt, with a new branch name, at the worktree a message
* of the Session waits for after a failed or stopped attempt.
* @param session - Session whose message waits.
* @param signal - request cancellation.
* @throws `forgePanel/not-waiting` when no message waits or an attempt is running.
*/
@Remote async retryWorktree(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,
* inside the wait of a message that waits for the restoration, which then
* goes on; 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, inside the wait of a message that waits for the removed
* worktree, which then goes on.
* @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`
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create` and `git push` results and from `plugin:forge-session/binding` records. After each push result the service looks up open change requests whose head is a pushed branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
Session-scoped change request bindings. The `forgeChangeRequests` projection derives them from `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` results and from `plugin:forge-session/binding` records. After each push result, and when a caller reports the branch a Session works on, the service looks up open change requests whose head is that branch and records the unbound ones as discovered. Watching a Session polls its visible change requests through `ctx.forge`.
```ts cordis-catalog
/**
@@ -325,6 +1002,18 @@ changeRequests(session: Session): SessionChangeRequest[]
*/
track(session: Session, url: string): void
/**
* Look up open change requests whose head is a branch the Session works on,
* and record the unbound ones as discovered, as after a push of that branch.
* A remote that no provider recognizes changes nothing; a failed lookup is
* logged and changes nothing.
* @param session - Session that receives the bindings.
* @param remote - URL of the remote that holds the branch.
* @param branch - branch name on that remote.
* @returns resolves once the lookup has recorded its bindings or failed.
*/
discoverBranch(session: Session, remote: string, branch: string): Promise<void>
/**
* Hide a visible binding. Appends a `dismiss` record; a URL that is not
* visibly bound leaves the log unchanged. Discovery never restores a
+2 -2
View File
@@ -15,8 +15,8 @@
en: b7b8a7b411aee94d
zh: 7298c20000945d97
/right-sidebar/navigation-ctx-sidebarright:
en: 0f80b5364cbad804
zh: db81652a5374fad1
en: c695e9aca92271f2
zh: 4b4d820c5a16bed1
/right-sidebar/slots-and-owner-props:
en: f58dc6ff6b0027a7
zh: 707722fcbd096efc
+1 -1
View File
@@ -88,7 +88,7 @@ Two opens are the navigation controller, and every way into the column calls one
Placement is the caller's option, never a type's property. The conversation calls `openResource(fileAddressFor(sessionId, cwd, path))` and, from a `read` tool row, adds `{ params: { line } }` from the call's 1-based `offset`; a guide entry box calls `tab.actions.openTab(entry.kind, { replaceTab: true })`; a file-tree row calls `tab.actions.openResource(address)`; the strip's add control calls `openTab('guide', { paneId, revealIfOpened: false })`.
`close(tabId)` closes a tab; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` returns the active pane's active tab; `isExpanded()` and `toggleExpanded()` read and flip the column, the flip recorded in the sequence. Reads answer for the no-Session case with `undefined` or `false`; writes need a mounted Session surface and throw without one rather than write into a surface nobody draws.
`focus(tabId)` makes a tab its pane's active tab; `split(paneId?)` splits the active docked pane, or the named one, and returns the new pane's id — or `undefined`, recording nothing, when the pane budget or the column's width forbids a split; `float(tabId, rect?)` lifts a tab into a floating pane; `dock(paneId)` returns a floating pane to the docked area. All four run the store's existing actions and record one history entry each; a target that does not exist or is already in the requested state is a no-op, and like `open` they throw without a mounted Session surface. `TabId`, `PaneId`, `TabRecord`, and `FloatRect` are re-exported from the package's `/client` entry so a caller needs no dockkit import.
+1 -1
View File
@@ -88,7 +88,7 @@ export function apply(ctx: Context): void {
落位是调用方的选项,从不是类型的属性。会话区调 `openResource(fileAddressFor(sessionId, cwd, path))`,`read` 工具行另加 `{ params: { line } }`(来自调用的 1 起 `offset`);引导页入口框调 `tab.actions.openTab(entry.kind, { replaceTab: true })`;文件树的行调 `tab.actions.openResource(address)`;tab 条的新增控件调 `openTab('guide', { paneId, revealIfOpened: false })`。
`close(tabId)` 关闭一个 tab;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 返回活动 pane 的活动 tab;`isExpanded()` 与 `toggleExpanded()` 读取与翻转这一列,翻转记入序列。无会话时读操作返回 `undefined` 或 `false`;写操作需要已挂载的会话面,没有时抛错而不是写进没人绘制的面。
`focus(tabId)` 让一个 tab 成为其 pane 的活动 tab;`split(paneId?)` 分割活动的停靠 pane 或点名的 pane,返回新 pane 的 id——pane 数预算或列宽不允许时返回 `undefined` 且不记账;`float(tabId, rect?)` 把一个 tab 浮出为浮窗 pane;`dock(paneId)` 把浮窗 pane 收回停靠区。四者都走 store 既有动作、各记一条历史;目标不存在或已处于目标状态时是空操作,与 `open` 一样在没有已挂载会话面时抛错。`TabId`、`PaneId`、`TabRecord`、`FloatRect` 自本包 `/client` 入口再导出,调用方无需引 dockkit。
+2 -2
View File
@@ -9,5 +9,5 @@
en: 53f643c5d30e00b2
zh: ba9e52efc62c7656
/git-worktrees/request-and-result:
en: 8391b091607600eb
zh: 6c735a337d60d566
en: fbab866bf824d6e0
zh: 73d2a1ee350e1434
+53 -2
View File
@@ -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,40 @@ interface CreatedWorktree {
}
```
```ts type-equiv
/**
* Stage of one creation or restoration, reported before it begins: `prepare`
* validates the request, allocates the checkout directory, and creates the
* branch, which a restoration creates again only when it is gone; `checkout`
* writes the files and, for a creation, 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 +99,26 @@ 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`. Only the missing target's unlocked registration is removed, 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.
* @param onPhase - receives each stage before it begins; it must not throw.
* @returns the directory and whether the branch was created again.
*/
async restore( agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise<RestoredWorktree>
```
Types: [Agent](core.md)
+53 -2
View File
@@ -14,7 +14,7 @@
## 请求与结果
请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。
请求只选择新名称与本地版本。结果标识创建的检出目录与确切提交;它不会改变 Session 的原始项目或写权限。可选的回调会在创建的每个阶段开始之前收到该阶段。恢复会在记录的目录重新检出已删除的检出目录:从它的分支检出;分支也已删除时,从它的基准提交检出。
```ts type-equiv
/** A new checkout from a locally available Git commit, branch, or tag. */
@@ -40,6 +40,40 @@ interface CreatedWorktree {
}
```
```ts type-equiv
/**
* Stage of one creation or restoration, reported before it begins: `prepare`
* validates the request, allocates the checkout directory, and creates the
* branch, which a restoration creates again only when it is gone; `checkout`
* writes the files and, for a creation, 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 +99,26 @@ 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`. Only the missing target's unlocked registration is removed, 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.
* @param onPhase - receives each stage before it begins; it must not throw.
* @returns the directory and whether the branch was created again.
*/
async restore( agent: Agent, request: RestoreWorktreeRequest, signal?: AbortSignal, onPhase?: (phase: CreateWorktreePhase) => void, ): Promise<RestoredWorktree>
```
Types: [Agent](core.zh.md)
+2 -2
View File
@@ -318,8 +318,8 @@
en: d5f5679abe86e316
zh: d5f5679abe86e316
/tool-schema-catalog/deepseek-ai-dsh-experimental-tool-worktree/create-worktree:
en: 3cf89e31f011ae9b
zh: 28e78b4fae2d075b
en: d8fe3c566ba2147c
zh: baf372cb9242670c
/tool-schema-catalog/deepseek-ai-dsh-tool-workflow:
en: bb38a9d29f46563c
zh: bb38a9d29f46563c
+2 -2
View File
@@ -2728,7 +2728,7 @@ Reads or changes the calling Session directory. Existing shells and processes re
### `create_worktree`
Create a new Git branch and worktree from a local commit, branch, or tag, then change this session's working directory to it. Defaults to HEAD and a generated name. Uncommitted files stay in the source checkout. Existing names fail. Use working_directory with cd to leave; the checkout and branch remain.
Create a new Git branch and worktree from a local commit, branch, or tag, then change this session's working directory to it. Uncommitted files stay in the source checkout. Existing names fail. Use working_directory with cd to leave; the checkout and branch remain.
```json
{
@@ -2736,7 +2736,7 @@ Create a new Git branch and worktree from a local commit, branch, or tag, then c
"properties": {
"name": {
"type": "string",
"description": "New branch name, also used as the checkout directory. Omit to generate a unique name."
"description": "New branch name, also used as the checkout directory: a few lowercase words joined by hyphens that describe the task, such as fix-login-retry. Omit to generate a unique name."
},
"from": {
"type": "string",
+2 -2
View File
@@ -2735,7 +2735,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
### `create_worktree`
从本地提交、分支或标签创建新的 Git 分支与工作树,再将本 Session 的工作目录切换到该位置。默认使用 HEAD 和自动生成的名称。未提交文件保留在源检出目录中。名称已存在时失败。使用 working_directory 的 cd 参数离开;检出目录与分支仍然保留。
从本地提交、分支或标签创建新的 Git 分支与工作树,再将本 Session 的工作目录切换到该位置。未提交文件保留在源检出目录中。名称已存在时失败。使用 working_directory 的 cd 参数离开;检出目录与分支仍然保留。
```json
{
@@ -2743,7 +2743,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"properties": {
"name": {
"type": "string",
"description": "New branch name, also used as the checkout directory. Omit to generate a unique name."
"description": "New branch name, also used as the checkout directory: a few lowercase words joined by hyphens that describe the task, such as fix-login-retry. Omit to generate a unique name."
},
"from": {
"type": "string",
@@ -0,0 +1,16 @@
# Bilingual-pair consistency record for guide.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 docs/upgrade-guide/v0.2.1-rc.3/generated-worktree-names/guide.md
/:
en: c799b94bab92d848
zh: 1e1f9cd572fc8665
/update-consumers-of-generated-worktree-names:
en: 210bc140e21cc68e
zh: dccc592eec81f483
/update-consumers-of-generated-worktree-names/change:
en: 7a7d682dde36dd7e
zh: b15e84c570d11ee2
/update-consumers-of-generated-worktree-names/migration:
en: f87101bee9c48150
zh: 3a79159711475af3
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "Generated worktree names replace the worktree- prefix and full UUID suffix with dsh- and Session-derived names."
---
# Update consumers of generated worktree names
English | [中文](guide.zh.md)
## Change
In v0.2.1-rc.3, omitting `name` from `create_worktree` or `ctx.worktrees.create` generates `worktree-<UUID>` by default. The next release generates `dsh-<first eight UUID digits from the Session id>-<four random hexadecimal digits>`, such as `dsh-6b1f02c4-9a3e`. A Session id without a UUID gets `dsh-<eight random hexadecimal digits>`.
Scripts and ignore rules that require the `worktree-` prefix or a full UUID suffix need updating. Explicit names, the configured checkout pool, and existing checkout directories and branches are unchanged. The `namePrefix` setting remains available, but it controls only the prefix, not the suffix format.
## Migration
1. Update scripts and ignore rules that recognize generated names. Prefer the returned `path` and `branch` fields over reconstructing them from the old pattern; the [worktree service reference](../../../../packages/experimental/worktree/README.md#use-this-package) describes these fields.
2. To retain the old prefix, set `config.namePrefix` to `worktree-` in the existing `@deepseek-ai/dsh-experimental-worktree` configuration row. This does not restore the full UUID suffix. Supply an explicit `name` when an integration requires a particular complete name.
3. Create a worktree with `name` omitted, inspect its returned `branch` and `path`, and confirm that the updated automation recognizes it. Existing worktrees require no renaming or data migration.
@@ -0,0 +1,20 @@
---
kind: upgrade-guide
description: "自动生成的 worktree 名称将 worktree- 前缀和完整 UUID 后缀改为 dsh- 前缀与基于 Session 的名称。"
---
# 更新依赖自动生成 worktree 名称的使用方
[English](guide.md) | 中文
## 变更
在 v0.2.1-rc.3 中,调用 `create_worktree` 或 `ctx.worktrees.create` 时省略 `name`,默认生成 `worktree-<UUID>`。下一版本默认生成 `dsh-<Session id 中 UUID 的前八位十六进制数>-<四位随机十六进制数>`,例如 `dsh-6b1f02c4-9a3e`。Session id 不含 UUID 时,生成 `dsh-<八位随机十六进制数>`。
依赖 `worktree-` 前缀或完整 UUID 后缀的脚本和忽略规则需要更新。显式名称、配置的检出父目录以及已有的检出目录和分支保持不变。`namePrefix` 配置项仍然可用,但只控制前缀,不控制后缀格式。
## 迁移
1. 更新识别自动生成名称的脚本和忽略规则。优先使用返回的 `path` 与 `branch` 字段,不要按旧模式重新拼接;[worktree 服务参考](../../../../packages/experimental/worktree/README.zh.md#use-this-package)说明了这些字段。
2. 如需保留旧前缀,在现有的 `@deepseek-ai/dsh-experimental-worktree` 配置行中将 `config.namePrefix` 设为 `worktree-`。这不会恢复完整 UUID 后缀;集成要求特定完整名称时,请显式传入 `name`。
3. 创建一个省略 `name` 的 worktree,检查返回的 `branch` 与 `path`,确认更新后的自动化能识别它。已有 worktree 无需改名或迁移数据。
@@ -30,8 +30,8 @@
en: ef2af36136c4a919
zh: 48d992e2c923a130
/deepseek-ai-dsh-client-ui-sidebar-right/ctx-sidebarright:
en: 4519c4633d160c2a
zh: e4019a62532f885a
en: 228794a144cb4065
zh: b0897365a1dfcd1e
/deepseek-ai-dsh-client-ui-sidebar-right/the-tab-domain:
en: 717b86e05b44ebdd
zh: 0f4f694d0ea1d7f8
+1 -1
View File
@@ -94,7 +94,7 @@ Two more seats extend what is already there: `sidebar.right.tab.guide` (chain) r
`preferNewPane: true` first asks the target docked pane to split under the normal two-pane and room rules, then falls back to that pane when no split is available. `replaceTab` takes precedence and disables this preference.
`close(tabId)` closes a tab; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook.
`close(tabId)` closes a tab; `hide(tabId)` closes a floating tab or collapses its docked column; `active()` reads the active tab. `isExpanded()` and `toggleExpanded()` read and drive the column's expansion; `toggleFullscreen(target)` applies the panel control’s display-mode action to a captured dock pane. Layout operations, for callers that arrange the column programmatically, each recorded like the gesture it stands in for: `focus(tabId)` focuses a tab and its pane; `split(paneId?)` splits a docked pane (the active one by default) under the same pane budget and room rule as the strip's control and returns the new pane's id, or `undefined` — recording nothing — when it cannot; `float(tabId, rect?)` takes a docked tab out into a panel; `dock(paneId)` returns a floating panel to the active docked pane. A tab or pane that does not exist, or already is where the call would put it, is left alone. The face exposes operations only: no layout snapshot, no operation log, no lookup by address. `_undo()` / `_redo()` step the on-screen surface's history; they are `@internal` — the sequence has no user-facing control, and these exist for tests. Commands act on the on-screen Session through its adopted store; with no Session on screen, or before the runtime mints its store, they throw rather than write into a surface nobody draws. `mounted` is the observable of the Session on screen: the selected Session while the Conversation fills the main column, `undefined` while a global panel replaces it or no Session is selected. The plugin derives it from the Session selection and `ctx.layout.panelInfo`, and it changes before React renders the change that causes it, so a component rendered in that commit reads the arriving Session and may act on it from its effects: the frame renders that Session's seat in the same pass, which mints its store. Seats never publish it; each reports only what it renders with: the room its docking kit measured, which `split` and `preferNewPane` apply to that Session's panes (unmeasured panes have room), and whether its frame width presents the panel fullscreen automatically, which the fullscreen command applies. The plan review's automatic open reads `mounted` through a bound hook.
`focusedTarget(element?)` resolves the current DOM pane and tab, including an embedding iframe; outside focus returns no page. `commandTarget(element?)` additionally allows an outside open to use the on-screen Session's active dock pane. Captures include the occurrence and navigation revision; `isTargetCurrent(target)` rejects changed Sessions, moved or reopened tabs, and intervening navigation. Focus and pointer activation remain separate from persisted layout selection.
@@ -94,7 +94,7 @@ tab 类型分两阶段注册,随包发布的引导类型走的正是别的包
`preferNewPane: true` 会先按普通的两格上限与空间规则尝试分割目标停靠格,无法分栏时回退到该格。`replaceTab` 优先并会禁用这项偏好。
`close(tabId)` 关闭一个 tab;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。
`close(tabId)` 关闭一个 tab;`hide(tabId)` 关闭浮动 tab 或收起其停靠列;`active()` 读取活动 tab。`isExpanded()` 与 `toggleExpanded()` 读取并驱动该列的展开;`toggleFullscreen(target)` 对捕获的停靠分栏执行面板控件的显示模式操作。布局操作供以编程方式安排该列的调用方使用,每个都像它替代的手势一样被记录:`focus(tabId)` 聚焦一个 tab 及其格;`split(paneId?)` 在与 tab 条控件相同的格预算与空间规则下分栏一个停靠格(默认活跃格),返回新格的 id,做不到时返回 `undefined`——且不记录任何东西;`float(tabId, rect?)` 把停靠 tab 浮出为浮窗;`dock(paneId)` 把浮窗放回活跃停靠格。不存在的 tab 或格、或已处于调用目标状态的,都原样不动。该接口只暴露操作:没有布局快照、没有操作日志、没有按地址查找。`_undo()` / `_redo()` 步进屏幕上停靠面的历史;它们是 `@internal`——序列没有面向用户的控件,这两个只为测试存在。命令通过屏幕上会话已采用的 store 作用于该会话;没有会话在屏幕上、或运行时尚未创建它的 store 时,命令 throw,而不是写进一个没人绘制的面里。`mounted` 是「屏幕上的会话」的可观察值:Conversation 占据主栏时为选中的会话,全局面板替代它或没有选中会话时为 `undefined`。插件从会话选择与 `ctx.layout.panelInfo` 推导它,它在 React 渲染引起变化的那次更新之前就已改变,因此同一次提交里渲染的组件读到的是到来的会话,并可以在自己的 effect 里对它行动:框架在同一轮渲染里渲染该会话的席位,席位随之创建它的 store。席位从不发布它;每个席位只上报它渲染时依据的事实:停靠套件测得的空间,`split` 与 `preferNewPane` 把它用于该会话的分栏(未测量的分栏视为有空间);以及框架宽度是否让面板自动全屏,全屏命令据此执行。计划审阅的自动打开通过绑定的 hook 读取 `mounted`。
`focusedTarget(element?)` 解析当前 DOM 分栏和标签页,也接受嵌入 iframe;焦点位于外部时不返回页面。`commandTarget(element?)` 另外允许外部打开动作使用屏幕上会话的活动停靠分栏。捕获值包含 occurrence 和导航版本;`isTargetCurrent(target)` 拒绝会话变更、已移动或重新打开的标签页,以及期间发生的导航。焦点和指针激活独立于持久化的布局选择。
@@ -219,6 +219,11 @@ export interface ISidebarRight {
* @param tabId - the tab to close.
*/
close(tabId: TabId): void
/**
* Hide a tab: close its floating occurrence, or collapse its docked column.
* @param tabId - the tab to hide; missing tabs are unchanged.
*/
hide(tabId: TabId): void
/**
* The active tab of the active pane.
* @returns the record, or `undefined` with no Session on screen.
@@ -457,6 +462,17 @@ export class SidebarRightController implements ISidebarRight {
this.closeIn(this.require().sessionId, tabId)
}
/**
* Hide a tab: close its floating occurrence, or collapse its docked column.
* @param tabId - the tab to hide; missing tabs are unchanged.
*/
hide(tabId: TabId): void {
const layout = this.mountedSurface()?.layout
if (layout?.tabs[tabId] === undefined) return
if (findTabPane(layout, tabId).host === 'float') this.close(tabId)
else if (layout.expanded) this.toggleExpanded()
}
/**
* The active tab of the active pane.
* @returns the record, or `undefined` without an on-screen surface.
@@ -624,11 +624,35 @@ describe('SidebarRightController — a tab\'s own actions', () => {
})
})
describe('SidebarRightController — hiding tabs', () => {
it('collapses a docked tab and closes a floating tab without toggling the dock', () => {
const { controller, publish, layout, tabOf } = harness()
publish()
controller.openResource('dsh-resource://file/session/s-test/a.txt')
const docked = tabOf('a.txt')
controller.hide(docked)
expect(layout().expanded).toBe(false)
expect(layout().tabs[docked]).toBeDefined()
controller.hide(docked)
expect(layout().expanded).toBe(false)
controller.openResource('dsh-resource://file/session/s-test/b.txt')
const floating = tabOf('b.txt')
controller.float(floating)
const expanded = layout().expanded
controller.hide(floating)
expect(layout().tabs[floating]).toBeUndefined()
expect(layout().expanded).toBe(expanded)
controller.hide(floating)
expect(layout().expanded).toBe(expanded)
})
})
describe('SidebarRightController — the readable slice', () => {
it('answers for the no-session case rather than throwing', () => {
const { controller } = harness()
expect(controller.isExpanded()).toBe(false)
expect(controller.active()).toBeUndefined()
controller.hide('missing' as TabId)
})
it('reports the last committed surface', () => {
+2 -2
View File
@@ -33,8 +33,8 @@
en: 3eb9b0e3365b2db3
zh: c73acc55dd897266
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/stylesheets:
en: e0f0bce3e166e71a
zh: bf5a68e7f440aca0
en: 925119bd420325b4
zh: 384ef1f890b059da
/deepseek-ai-dsh-client-ui-theme/understand-the-implementation/scrollbar-rebinding:
en: bf76e7c34a331595
zh: 5accd0260ef7c61e
+1 -1
View File
@@ -65,7 +65,7 @@ The style installer publishes `--dsh-hairline-width` on the document root and up
`base.css` owns the shared radius scale, the font tokens, and settings-card material aliases. `--dsh-font-family-text-default` and `--dsh-font-family-code-default` hold the built-in stacks on `:root`; on `body`, `--dsw-font-family` and `--ds-font-family-code` place `--dsh-font-family-text` and `--dsh-font-family-code` ahead of those stacks, and an unset list resolves to the built-in stack (repeated once, which font matching ignores). The material aliases resolve on `body`, alongside the active palette. Follow [Web styling](../../../docs/web-styling.md#corner-radii-and-settings-cards) when choosing component radii.
`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `--dsw-alias-bg-document-selection` uses blue-500 at 40% opacity in both themes for selections over original document colors. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
`src/styles/` holds eight sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `focus.css`, `onboarding.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens; `--dsw-alias-state-done-primary` marks finished work, such as a merged change request, with violet-600 in the light palette and violet-400 in the dark palette. `--dsw-alias-bg-document-selection` uses blue-500 at 40% opacity in both themes for selections over original document colors. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
[`focus.css`](src/styles/focus.css) provides a `:focus-visible` fallback that names the ring colour through `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` and the standard width through `--dsw-focus-ring-width`, never the outline style — so a control that disables its outline stays paintless, and one that declares no ring keeps the standard geometry instead of Chromium's `auto 1px`. The theme resolves this blue to `#4176E6` in light mode and `#7AAAFF` in dark mode. Component outlines and focus-ring shadows use the same colour expression, including rings on descendants and pseudo-elements. `--dsw-focus-ring-width` (2px) is the standard width; dense tables and toolbars may keep 1px, and offsets remain component-owned.
+1 -1
View File
@@ -65,7 +65,7 @@ Desktop 的固定快捷键 `Cmd`/`Ctrl` + `+`(或 `=`)和 `Cmd`/`Ctrl` + `-`
`base.css` 持有共享圆角尺度、字体 token 与设置卡片材质别名。`--dsh-font-family-text-default` 与 `--dsh-font-family-code-default` 在 `:root` 上保存内置字体栈;在 `body` 上,`--dsw-font-family` 与 `--ds-font-family-code` 把 `--dsh-font-family-text` 与 `--dsh-font-family-code` 排在这些字体栈之前,未设置的列表解析为内置字体栈(重复一次,字体匹配会忽略重复项)。材质别名在 `body` 上随当前色板解析。组件圆角选择遵循 [Web 样式参考](../../../docs/web-styling.zh.md#corner-radii-and-settings-cards)。
`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`--dsw-alias-bg-document-selection` 在两种主题中均使用 40% 不透明度的 blue-500,供保留文档原色的选区使用。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
`src/styles/` 下有八张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`focus.css`、`onboarding.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token;`--dsw-alias-state-done-primary` 标记已完成的工作(例如已合并的变更请求),浅色主题为 violet-600,深色主题为 violet-400。`--dsw-alias-bg-document-selection` 在两种主题中均使用 40% 不透明度的 blue-500,供保留文档原色的选区使用。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
[`focus.css`](src/styles/focus.css) 提供 `:focus-visible` 兜底:通过 `var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary))` 声明焦点环颜色,并通过 `--dsw-focus-ring-width` 声明标准宽度,但不声明轮廓样式——因此禁用轮廓的控件仍然不绘制,而没有自有焦点环的控件保持标准几何,而不是落到 Chromium 的 `auto 1px`。主题将该蓝色解析为浅色模式的 `#4176E6` 和深色模式的 `#7AAAFF`。组件轮廓与焦点环阴影使用同一颜色表达式,包括后代和伪元素上的环。`--dsw-focus-ring-width`(2px)是标准宽度;密集表格与工具栏可以保留 1px,offset 仍由组件决定。
@@ -83,6 +83,8 @@ body {
--dsw-static-red-600: rgb(236, 19, 19);
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
--dsw-static-violet-400: rgb(167, 139, 250);
--dsw-static-violet-600: rgb(124, 58, 237);
}
body[data-ds-dark-theme] {
@@ -167,6 +169,8 @@ body[data-ds-dark-theme] {
--dsw-static-red-600: rgb(236, 19, 19);
--dsw-static-red-600-a08: rgb(236 19 19 / 8%);
--dsw-static-red-900: rgb(87, 12, 12);
--dsw-static-violet-400: rgb(167, 139, 250);
--dsw-static-violet-600: rgb(124, 58, 237);
}
body {
@@ -259,6 +263,7 @@ body {
--dsw-alias-file-diff-deleted-bg: rgb(252, 230, 226);
--dsw-alias-file-diff-deleted-gutter: rgb(253, 236, 233);
--dsw-alias-file-diff-deleted-marker: rgb(186, 39, 35);
--dsw-alias-state-done-primary: var(--dsw-static-violet-600);
--dsw-alias-state-error-primary: var(--dsw-static-red-600);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-300);
@@ -382,6 +387,7 @@ body[data-ds-dark-theme] {
--dsw-alias-file-diff-deleted-bg: rgb(60, 31, 27);
--dsw-alias-file-diff-deleted-gutter: rgb(40, 19, 14);
--dsw-alias-file-diff-deleted-marker: rgb(250, 66, 62);
--dsw-alias-state-done-primary: var(--dsw-static-violet-400);
--dsw-alias-state-error-primary: var(--dsw-static-red-400);
--dsw-alias-state-error-secondary: var(--dsw-static-red-400);
--dsw-alias-state-idle-primary: var(--dsw-static-neutral-600);
+4 -4
View File
@@ -18,11 +18,11 @@
en: b55aeb927737b90d
zh: 0b215fcb5c2c7908
/packages-experimental/packages:
en: c206b6cf97a74807
zh: ee2326cfbd71cf49
en: b1f5913dd5384887
zh: 512a73927d199675
/packages-experimental/related-documentation:
en: 3add3dbe9b625de7
zh: cacba37c7834ff9d
en: 40797d4849455966
zh: bb91a68dd995887c
/packages-experimental/dev-note:
en: 5de1d6304e00c58f
zh: 8d8225361cfa29e5
+3 -1
View File
@@ -98,6 +98,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 | — |
-----
@@ -106,7 +108,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.
+3 -1
View File
@@ -98,6 +98,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 面板组合 | — |
-----
@@ -106,7 +108,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: 27cf01b13f177106
zh: c4b56ad7dfe7b1c7
/deepseek-ai-dsh-experimental-client-ui-forge:
en: 8ca3d802a44c6fd1
zh: 2723c9e28ecea7f7
/deepseek-ai-dsh-experimental-client-ui-forge/summary:
en: 6917eb21c7be3560
zh: dcb544919429b88a
/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: 675b24649622d158
zh: 3c27fc1d7e20731f
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
en: 0cb26601d077a723
zh: af04ea90c3d3b704
/deepseek-ai-dsh-experimental-client-ui-forge/further-exploration:
en: fa4d8c579ffc49ff
zh: 5856ed7469e4585b
/deepseek-ai-dsh-experimental-client-ui-forge/model-experience:
en: c24fdd69e05d9f5d
zh: dd58839caace3ed3
/deepseek-ai-dsh-experimental-client-ui-forge/model-experience/kv-cache-effect:
en: e29ceb229a7c5b11
zh: 6f6364154b43bb94
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work:
en: e3044930521b6509
zh: f7c3241c4e956938
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -0,0 +1,128 @@
---
description: "GUI panel for the change 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 change requests a Session created or pushed and their changes, 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 and checks sign-in, streams the Session's [bound change requests](../forge-session/README.md) with their polled status, reads local changes and change request diffs, and creates a requested worktree while the first message waits in the Session's inbox. The Client half adds a sidebar tab, a changes view, header entries, a New Session option, a creation card, and action toasts.
## 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).
Panel text follows the application language: Chinese, English, Japanese, Spanish, French, German, Russian, or Portuguese.
- **Worktree option** — on the New Session screen, Worktree sits at the right end of the row with the workspace and mode pickers. Checking it shows the base branch, the current branch by default; the menu lists recent local branches. The option is disabled, with the reason in its tooltip, outside a Git checkout, in a read-only Session, and when the checkout or its Git directory lies outside the Session's writable root, as for a project that is itself a linked worktree. Sending the first message hands it to the Session at once, and it waits there while the branch `dsh-<first 8 hexadecimal digits of the Session id>-<4 random hexadecimal digits>` is created from the base under the repository's `.agents/worktrees/` and its checkout becomes the Session's working directory; the message's turn starts once the worktree exists.
- **Creation card** — while the worktree is created, a card above the composer shows the new branch and its two steps, creating the branch and checking out files; More details adds the branch and base. Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting, and the card shows the reason of a failure: Retry tries again with a new branch name, and Continue without a worktree withdraws the request so the message goes on in the current checkout.
- **Removed worktree** — when the worktree the Session works in has been removed outside the Session, a card above the composer says so, and a message sent meanwhile waits without starting its turn until the worktree is restored or left; the card then says that the message goes on afterwards. Restore worktree, also offered as Restore in the tab, checks the branch out again at the same directory, or creates the branch again at the commit it started from when the branch is gone too; while it runs, the card follows its two steps, preparing the worktree and checking out files, and the tab's Restore turns into a spinner. Continue in the project moves the Session to the source checkout. Meanwhile the tab's first row shows a warning glyph.
- **Header button** — appears when the Session has bound change requests or its directory is inside a Git checkout, and shows or hides the tab. A red dot marks an open change request with failed checks, requested changes, or conflicts.
- **Tab** — the first row names the checked-out branch, or the abbreviated commit on a detached HEAD, after a folder glyph for the project directory or a branch glyph for a worktree, and the kind of checkout, which is the worktree the Session created with its base, another worktree, or the project directory; its tooltip adds the path with the Host home shortened to `~`, its button copies the full path, and for a removed worktree it offers Restore. Below it, each change request card shows a glyph for its state, its title, which opens it in the browser, and its number; the glyph tells open, draft, merged, and closed apart, and marks an open one that is not a draft with failed checks, requested changes, or conflicts as needing attention, as the header button's red dot does. The card's header also offers Open in browser, Copy change request link, and Remove from this Session, whose toast offers Undo. A collapsed card adds the checks of an open change request and the most urgent of requested changes, conflicts, or draft, or when a merged or closed one ended. An expanded card shows the head and base branches, then one row per fact: for an open change request the checks, whose row opens the list of checks with failed ones first and their durations and links, the review decision with unresolved threads, and the merge state, or when a merged or closed one ended; and the changed lines, formatted in the selected UI language, whose row opens the changes view. An open draft also offers Ready for review; a change request the host would merge now offers Merge, whose menu lists the methods the repository allows with the user's usual one chosen and pins the displayed head when opened. Confirm merge uses that head; a polled head change disables confirmation until the menu is reopened after review. A toast reports each outcome, and new commits since that head stop the merge. When exactly one bound change request is open, its card starts expanded. Refresh inspects the checkout, checks sign-in, and polls every change request again; its icon turns until the inspection and the sign-in check end. A banner shows the `gh auth login` command when sign-in is missing.
- **Local changes card** — while the `branch` scope changes files and no open change request has the checked-out branch as its head, a card above the change requests names the ref it compares with, such as `HEAD → origin/main`, and shows the added and deleted lines and the number of files, which open the changes view; its file list, which names files as the changes view does, opens the view at one file. Create change request, offered once the code host repository is recognized, sends the Session a user message in the UI language that asks the Agent to create a change request for these changes, and a toast reports whether it was sent.
- **Changes view** — The Changes row of an expanded change request card and the local changes card open a right-sidebar tab that lists the changed files. A local view shows Branch by default: the working tree and untracked files against the fork point, which is the base commit of the worktree the Session created, else the merge base of HEAD with the default branch of the code host remote. Uncommitted compares them with HEAD, Unstaged with the index, and Staged compares the index with HEAD. A change request view shows the host's diff at its head and says how many commits the checked-out branch has beyond that head when it is the change request's head branch. The toolbar jumps to a file, totals the changed lines, refreshes, and switches between two columns and one and between wrapped and cut long lines. Files are named before their directories, which use smaller subdued type and shorten first; hovering one shows its complete path, and both paths for a renamed file. Each file shows badges for a new, deleted, or renamed file, the last naming the old file name, or the old path when only the directory changed, and, in the Branch scope, an untracked one, and a menu that copies its path and opens the whole file in the sidebar for local changes or its page at the head in the browser for a change request. Unchanged lines between hunks collapse into rows that expand from the file's new side: the working tree, the index for Staged, or the head commit through the code host; a file that changed after the diff was read stays collapsed until the view reads again. A binary file and an untracked file over `maxDiffBytes` show a note instead of their lines, a file whose hunks draw more than 1,000 lines starts as one collapsed strip that names its changed lines and draws them when opened, and a file below files that change more than 5,000 lines starts collapsed. The view reads again on Refresh and the page refresh shortcut, on each navigation, when the change request's head moves, and when the Branch scope's summary changes; a change request's view first waits for the status that names its head, unless no provider serves it or that poll failed. While a read replaces changes already shown, the Refresh icon turns. A change request whose diff the code host or its provider refuses over a size limit, such as a GitHub diff over 300 files, says its changes are too large to show here and offers View in browser; another failed read offers Retry and, for a change request, View in browser. A change request's diff is downloaded once per head commit and base branch while it stays cached, so reopening, refreshing, and navigating at the same head read nothing from the host.
`gitCommand`, `gitTimeoutMs`, `graceMs`, `maxOutputBytes`, `readinessTimeoutMs`, `maxBranches`, `worktreePrefix`, `maxDiffBytes`, `maxUntrackedFiles`, and `maxCachedDiffBytes` 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. A failed parallel Git read cancels and joins its siblings. 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. When an inspection finds an upstream branch on a remote a provider recognizes, the panel asks `ctx.forgeSession.discoverBranch` to bind that branch's open change requests, once per remote and branch until a refresh. A sign-in probe runs when the recognized host changes and on refresh, within `readinessTimeoutMs`; a refresh settles once the inspection and the probe it started have ended, and poll results arrive later.
Inspection runs `rev-parse --show-toplevel`, `rev-parse --path-format=absolute --git-common-dir`, `rev-parse --path-format=absolute --git-dir`, `symbolic-ref --short HEAD`, `rev-parse --short HEAD`, `rev-parse --abbrev-ref @{upstream}`, and `remote -v`; `branches` runs `symbolic-ref` and `for-each-ref --count=<maxBranches>`. Inspection then sizes the `branch` scope for the local changes card: it finds the fork point, runs `diff --numstat -z -M` against it, and lists untracked files. 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, Git, and common Git directories Git prints are resolved through `ctx.fs` into the canonical process paths that the working-directory service records; a checkout whose Git directory differs from the common one is a linked worktree. 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` refuses with `forgePanel/session-started` after a turn started or the worktree was created, with `forgePanel/worktrees-unavailable` without a `worktrees` service, and with `forgePanel/invalid-branch` for an empty branch; `cancelWorktree` refuses only with the first and needs no `worktrees` service; both forget a failed attempt. When a message enters the inbox of a Session whose request is open, an `agent/inbox/inserted` listener starts maintenance of the Session's Agent before the delivery wakes it, so the Agent latches the wake and starts the message's turn once that hold ends. The hold makes one attempt with a new branch name, publishing the running stage in the panel state with `held` set and `waiting` as `creation`, and records `created` with the source repository root. After a failed attempt, whose branch, base, failed step, and reason stay in the panel state as `failure`, or after `stopCreation`, the hold waits: `retryWorktree` makes another attempt, refused with `forgePanel/not-waiting` while none waits or one runs, and withdrawing the request lets the message go on in the current checkout. Each message that enters while the Agent is idle and the Session works in its created worktree starts a hold that checks whether the worktree is missing; while it is, the hold waits with `waiting` as `restoration`, and `restoreWorktree` or `continueInProject` runs inside it and then lets the message go on; a check that cannot answer lets the message go at once. The Agent's cancellation and the Remote's stop end a hold, and a message that enters while the Agent runs a turn or other maintenance gets none. The worktree service queues the working-directory notice, which the first step claims. An `agent/pre-step` listener creates the worktree at step 1 of a Session whose request is still open, which covers a message no hold took; it moves the notice into the proposed messages, and a failure ends the turn with the request kept. `restoreWorktree` and `continueInProject` run as maintenance of their own while no message waits; `restoreWorktree` publishes its running step as `restoration`, and clears `missing` in the same state that ends it. The bundle isolates its `worktrees` service, so enabling Git Worktrees as well leaves two independent services. `track` rejects a URL no provider recognizes with `forgePanel/unrecognized-url`. `markReady` and `merge` act only on a visible binding of the Session that a provider serves, else they refuse with `forgePanel/not-bound`; `merge` passes the head commit the card showed, and a moved head refuses with `forgePanel/stale-head`, while any other refusal is `forgePanel/action-failed` with the host's reason.
Restoration records `restoring` before mutation and `restored` only after success. Projection version 3 retains incomplete restoration across reloads. Directory existence alone cannot release a waiting message: the checkout root and shared Git directory must match the recorded worktree. Plugin unload cancels and joins all creation, restoration, and waiting tasks before releasing panel readers and the projection.
Each inspection also decides whether the worktree is missing: the Session's directory lies inside the created worktree, `ctx.fs.lstat` finds no directory there, and `show-ref` in the source repository tells whether the branch still exists.
`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` and `merge-base HEAD`; without one the `branch` scope refuses with `forgePanel/no-fork-point`. It runs `diff --no-ext-diff --no-textconv --no-color -M` with fixed `a/` and `b/` prefixes and `core.quotePath=false` against the fork point, against HEAD (the empty tree before the first commit), against the index, or with `--cached` against HEAD. Untracked files, at most `maxUntrackedFiles` with the rest counted as omitted, come from `ls-files --others --exclude-standard -z` and are read through `ctx.fs` as new files, binary when their first 8,000 bytes hold a NUL byte or they are not UTF-8. A patch, file list, or file over `maxDiffBytes` refuses with `forgePanel/diff-too-large`, and a directory outside a checkout with `forgePanel/not-a-checkout`. The Host parses patches with jsdiff into files and hunks, so the browser receives no raw patch. `localText` reads the new side of one file, from the index through `show :<path>` for Staged and through `ctx.fs` otherwise, and refuses a path that leaves the checkout. `changeRequestDiff` and `changeRequestText` act only on a visible binding a provider serves, else they refuse with `forgePanel/not-bound`; they read the head commit of the binding's latest status through the provider's `diff` and `fileText`, refuse a `too-large` failure with `forgePanel/diff-too-large`, and refuse with `forgePanel/diff-failed` before a status was read or when the host refuses otherwise. The Host keeps the patches `changeRequestDiff` read, one per change request at its head commit and base branch, and drops the least recently read once they exceed `maxCachedDiffBytes`; a later read at the same head and base answers from memory for any Session, and a failed read is not kept. The commits the checkout has beyond the head come from `symbolic-ref`, `cat-file -e`, and `rev-list --count`, and are null when the checked-out branch is not the head branch, the head commit is absent, or Git fails.
The browser entry mounts the Remote, then the UI. One stream per Session feeds the tab, the header button, the New Session option, and the creation card; it opens with the first subscriber, closes with the last, reopens after a transport reset, and keeps the last state while the Session scope is retained. Releasing that scope closes the stream and removes its cached state. The New Session option fills `conversation.hero.options` and the worktree card `conversation.input.dock`. The worktree card shows a running creation, with Cancel calling `stopCreation` while a message waits for it, or the failed or stopped attempt a message waits after, with Retry calling `retryWorktree` and Continue without a worktree calling `cancelWorktree`. The tab shortens paths with the Host home from the Remote's Host facts, read again after each transport reset. The tab reports its visibility to the header button's pressed state. The header button collapses a docked tab or closes its floating tab. The toast lives in a `shell.overlay` entry so it outlives the tab. The change request card takes a change request, its snapshot, and optional actions, so another surface can reuse it. The changes view is the `forge-changes` resource tab type: its addresses, `dsh-resource://forge-changes/<session>/local` and `dsh-resource://forge-changes/<session>/change/<url>`, name the source, and a navigation may name a file to scroll to. Its body keeps the scope and layout choices of each tab in a slot store, reads through the Remote, and highlights lines with the shared code highlighter; a chip title shows its name with the changes icon.
| Source | Responsibility |
|---|---|
| [`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, waiting messages, and creation 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/diff.ts`](src/diff.ts) | Patch parsing, untracked files as additions, and numstat records |
| [`src/local-diff.ts`](src/local-diff.ts) | Fork point, local scopes, new-side text, and the `branch` scope summary |
| [`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/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | Local changes card |
| [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | Changes view |
| [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | One file of the changes view |
| [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | Changed file label: name, then directory |
| [`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 |
</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 change 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 and the Session's conversation: the notice about a Session's new worktree joins the first request's messages, Continue in the project queues the same kind of notice for the next request, and Create change request sends a user message that asks the Agent for a change request. Inspection, status, diffs, restoring, and the rest of 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 and the message Create change request sends are 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.
- **Retained checkouts** — the panel creates a worktree only before a Session's first step and does not remove existing checkout directories or branches; a failed or cancelled attempt can leave its branch or checkout behind. Branch names come from the Session id and random digits, not from the Session title, which does not exist yet.
- **Messages during a turn** — a message that enters while the Agent runs a turn or other maintenance gets no hold: a requested worktree is then created at the first step, where a failure ends the turn without the message, and a removed worktree is not checked, so the working-directory service moves the Session back to the project directory.
- **Waiting messages** — while a message waits, the Agent stays idle in maintenance and the composer offers no stop; the card's actions, the Agent's cancellation, or the Remote's stop release it, and callers that wait for the Agent to become idle wait as well.
- **Restoring** — restoring recreates the checkout from the branch, or from the commit the branch started from when the branch is gone; changes that were never committed are lost with the removed directory. The [worktree service](../worktree/README.md#use-this-package) validates the recorded target and removes only its missing, unlocked registration. An existing or incomplete directory stays blocked; preserve its files and remove the directory before retrying, or continue in the source project.
- **Linked-worktree projects** — the option is unavailable when the project is itself a linked worktree: under workspace-write its Git directory lies outside the writable root, so neither the new branch nor later commits could be written.
- **Changes scopes** — deferred: scopes for one commit and for the last turn. The Branch scope needs a remote whose default branch HEAD shares history with, or a worktree the Session created.
- **Large changes** — a local patch over `maxDiffBytes` shows no files, and a change request diff over the size limit of the host or its provider shows no files and offers only View in browser. Without wrapping, the side-by-side view cuts long lines at the column edge.
- **Review page** — deferred: a left-navigation page that lists the user's change 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,128 @@
---
description: "展示 Session 创建的变更请求、并让新 Session 以 worktree 开始的 GUI 面板,以及为它们提供数据的宿主 Remote。"
kind: "package-reference"
---
# @deepseek-ai/dsh-experimental-client-ui-forge
[English](README.md) | 中文
## 概述
这个可选的双端包展示 Session 创建或推送的变更请求及其变更,并让新 Session 在自己的 Git worktree 中开始。宿主端是 `forgePanel` Remote:它以只读方式检查 Session 的检出、识别代码托管平台并检查登录状态,以流的形式推送 Session 的[已绑定变更请求](../forge-session/README.zh.md)及其轮询状态,读取本地变更与变更请求的 diff,并在第一条消息于 Session 的收件箱中等待时创建所请求的 worktree。客户端增加一个侧边栏标签页、一个变更视图、标题栏中的入口、一个新会话选项、一张创建卡片,以及操作结果的 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)时,该选项不可用,提示中说明原因。发送第一条消息后,消息立即交给 Session 并在那里等待,同时从基准分支在仓库的 `.agents/worktrees/` 下创建分支 `dsh-<Session id 前 8 位十六进制数>-<4 位随机十六进制数>`,并把它的检出设为 Session 的工作目录;worktree 建好后这条消息的这一轮才开始。
- **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。
- **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。
- **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。
- **标签页**——顶部一行显示当前检出的分支(分离 HEAD 时为缩写提交),项目目录前显示目录图标、worktree 前显示分支图标,并说明检出的类型(Session 创建的 worktree 及其基准、其他 worktree 或项目目录);它的提示给出路径,宿主主目录缩写为 `~`,旁边的按钮复制完整路径;worktree 已被删除时提供“恢复”。其下每张变更请求卡片显示状态图标、标题(点击在浏览器中打开)与编号;图标区分打开、草稿、已合并与已关闭,打开中的变更请求(草稿除外)检查失败、被要求修改或有冲突时显示为“需要处理”,与标题栏按钮的红点一致。卡片右上角还提供“在浏览器中打开”“复制变更请求链接”与“从本会话移除”,移除后的 toast 提供“撤销”。折叠的卡片还显示打开的变更请求的检查,以及被要求修改、冲突、草稿中最需要注意的一项;已合并或已关闭的则显示结束时间。展开的卡片显示源分支与目标分支,下面每项一行:打开的变更请求依次是检查(点击该行弹出检查列表,失败的排在前面,带耗时与链接)、评审结论与未解决讨论数、合并状态,已合并或已关闭的显示结束时间;最后是按所选界面语言格式化的变更行数,点击该行打开变更视图。打开中的草稿还提供“标记为可评审”;主机现在就会合并的变更请求提供“合并”,其菜单列出仓库允许的合并方式并选中用户常用的一种,打开时固定卡片显示的 head。“确认合并”使用这个 head;轮询发现 head 变化后禁用确认,需重新检查并打开菜单。每次操作的结果都以 toast 报告;该 head 之后有新提交时不会合并。恰好有一个已绑定的变更请求处于打开状态时,它的卡片默认展开。刷新会重新检查检出与登录状态,并立即再次轮询每个变更请求;检查与登录状态确认结束前,刷新图标持续旋转。缺少登录时,横幅显示 `gh auth login` 命令。
- **变更卡片**——`branch` 范围有文件变更、且没有以当前检出分支为源分支的打开中变更请求时,变更请求列表上方的卡片显示比较的基准(例如 `HEAD → origin/main`)以及新增、删除行数与文件数,点击打开变更视图;展开的文件列表像变更视图一样标出各个文件,点击某个文件时,变更视图定位到该文件。识别出代码托管仓库后,卡片提供“创建变更请求”:它以界面语言向 Session 发送一条用户消息,请 Agent 为这些变更创建变更请求,并以 toast 报告是否已发送。
- **变更视图**——展开的变更请求卡片上的“变更”一行与变更卡片会打开一个右侧边栏标签页,列出所有变更的文件。本地变更默认显示“分支”:工作树与未跟踪文件相对于分叉点的变更;分叉点是 Session 创建的 worktree 的基准提交,否则是 HEAD 与代码托管远端默认分支的合并基准。“未提交”与 HEAD 比较,“未暂存”与暂存区比较,“已暂存”比较暂存区与 HEAD。变更请求的视图显示托管平台在其 head 处的 diff;当前检出分支就是该变更请求的源分支时,还会说明本地分支比该 head 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。
`gitCommand`、`gitTimeoutMs`、`graceMs`、`maxOutputBytes`、`readinessTimeoutMs`、`maxBranches`、`worktreePrefix`、`maxDiffBytes`、`maxUntrackedFiles` 与 `maxCachedDiffBytes` 列在[配置目录](../../../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`。每个变更请求在后续轮询失败时保留最近一次轮询得到的快照。并行 Git 读取失败时会取消并等待同批读取结束。检查一次只运行一个,最多再追加一次;Session 已离开的目录的结果会被丢弃。代码托管平台按远端顺序识别:先是上游所在的远端,然后是 `origin`,最后是其余远端。检查发现提供方识别的远端上有上游分支时,面板调用 `ctx.forgeSession.discoverBranch` 绑定该分支打开状态的变更请求;同一远端与分支在刷新前只查找一次。识别出的托管平台变化时以及刷新时,会在 `readinessTimeoutMs` 内检查登录状态;一次刷新在它触发的检查与登录状态检查结束后完成,轮询结果之后才到达。
检查运行 `rev-parse --show-toplevel`、`rev-parse --path-format=absolute --git-common-dir`、`rev-parse --path-format=absolute --git-dir`、`symbolic-ref --short HEAD`、`rev-parse --short HEAD`、`rev-parse --abbrev-ref @{upstream}` 与 `remote -v`;`branches` 运行 `symbolic-ref` 与 `for-each-ref --count=<maxBranches>`。之后,检查会为变更卡片统计 `branch` 范围的规模:找出分叉点,对它运行 `diff --numstat -z -M`,并列出未跟踪文件。每个命令都在 Session 的只读沙箱策略下运行,禁用可选的索引写入、钩子、文件系统监视器、提示与对象的延迟获取,移除继承的 Git 选择器与携带密钥的变量,并各自受 `gitTimeoutMs` 时限与每个输出流的 `maxOutputBytes` 上限约束。Git 打印的顶层目录、Git 目录与公共 Git 目录经 `ctx.fs` 解析为工作目录服务记录的规范进程路径;Git 目录与公共 Git 目录不同的检出是链接 worktree。工作树选项在 `danger-full-access` 沙箱模式下可用,在 `read-only` 下不可用,在 `workspace-write` 下只有两个目录都位于 Session 沙箱策略的规范工作区根内时才可用。
worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主侧的 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。一轮开始或 worktree 已创建之后,`requestWorktree` 以 `forgePanel/session-started` 拒绝;没有 `worktrees` 服务时以 `forgePanel/worktrees-unavailable` 拒绝;分支名为空时以 `forgePanel/invalid-branch` 拒绝;`cancelWorktree` 只以第一种拒绝,也不需要 `worktrees` 服务;两者都会忘掉失败的尝试。消息进入有未完成请求的 Session 的收件箱时,一个 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Session 所属 Agent 的维护任务,因此 Agent 会暂存唤醒,等这次暂留结束后才开始这条消息的这一轮。暂留使用新分支名进行一次尝试,在面板状态中发布正在进行的阶段,设置 `held`,`waiting` 为 `creation`,并记录带源仓库根目录的 `created`。尝试失败(其分支、基准、失败步骤与原因以 `failure` 留在面板状态中)或经 `stopCreation` 停止后,暂留继续等待:`retryWorktree` 再做一次尝试,没有消息等待或已有尝试进行时以 `forgePanel/not-waiting` 拒绝;撤回请求则让消息在当前检出中继续。Agent 空闲且 Session 在所创建 worktree 中工作时,进入的每条消息都会启动一次暂留,检查该 worktree 是否缺失;缺失期间暂留以 `waiting` 为 `restoration` 等待,`restoreWorktree` 或 `continueInProject` 在暂留内运行,之后放行消息;检查无法给出结果时立即放行。Agent 的取消与 Remote 的停止都会结束暂留;Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留。worktree 服务把工作目录通知排进队列,由第一步认领。一个 `agent/pre-step` 监听器在请求仍未完成的 Session 的第 1 步创建 worktree,覆盖没有被暂留的消息;它把通知移入本步提议的消息,失败时这一轮结束、请求保留。没有消息等待时,`restoreWorktree` 与 `continueInProject` 以各自的维护任务运行;`restoreWorktree` 以 `restoration` 发布正在进行的步骤,并在结束恢复的同一状态中清除 `missing`。套件把它的 `worktrees` 服务隔离,因此同时启用 Git Worktrees 时会有两个互不影响的服务。`track` 以 `forgePanel/unrecognized-url` 拒绝没有提供方识别的 URL。`markReady` 与 `merge` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;`merge` 带上卡片显示的 head 提交,head 已变化时以 `forgePanel/stale-head` 拒绝,其他拒绝都是带主机原因的 `forgePanel/action-failed`。
恢复在修改前记录 `restoring`,仅在成功后记录 `restored`。投影版本 3 在重载后保留未完成的恢复。目录存在本身不能放行等待的消息:检出根目录与公共 Git 目录必须匹配记录的工作树。插件卸载会取消并等待所有创建、恢复与消息等待任务,然后释放面板读取器和投影。
每次检查还会判断 worktree 是否缺失:Session 的目录位于所创建的 worktree 内,`ctx.fs.lstat` 在那里找不到目录,再由源仓库中的 `show-ref` 判断分支是否还在。
Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommit` 为分叉点;否则按代码托管远端的顺序,对每个远端依次运行 `symbolic-ref --quiet --short refs/remotes/<remote>/HEAD` 与 `merge-base HEAD` 找出分叉点;找不到时 `branch` 范围以 `forgePanel/no-fork-point` 拒绝。它以固定的 `a/`、`b/` 前缀和 `core.quotePath=false` 运行 `diff --no-ext-diff --no-textconv --no-color -M`,分别与分叉点、HEAD(第一次提交之前为空树)、暂存区比较,或带 `--cached` 与 HEAD 比较。未跟踪文件由 `ls-files --others --exclude-standard -z` 列出,最多 `maxUntrackedFiles` 个,其余计为未列出;它们经 `ctx.fs` 读取并作为新文件加入,前 8,000 字节含 NUL 字节或不是 UTF-8 时视为二进制。补丁、文件列表或文件超过 `maxDiffBytes` 时以 `forgePanel/diff-too-large` 拒绝,目录不在检出中时以 `forgePanel/not-a-checkout` 拒绝。宿主用 jsdiff 把补丁解析为文件与 hunk,浏览器不会收到原始补丁。`localText` 读取一个文件的新版本:“已暂存”经 `show :<path>` 从暂存区读取,其余经 `ctx.fs` 读取;离开检出的路径会被拒绝。`changeRequestDiff` 与 `changeRequestText` 只作用于该 Session 中由提供方服务的可见绑定,否则以 `forgePanel/not-bound` 拒绝;它们经提供方的 `diff` 与 `fileText` 读取绑定最新状态中的 head 提交,`too-large` 失败以 `forgePanel/diff-too-large` 拒绝,尚未读到状态或托管平台以其他原因拒绝时以 `forgePanel/diff-failed` 拒绝。Host 保留 `changeRequestDiff` 读到的补丁,每个变更请求一份,按 head 提交与目标分支区分,总量超过 `maxCachedDiffBytes` 时丢弃最久未读的;之后在同一 head 与目标分支下的读取不论来自哪个 Session 都从内存返回,读取失败的结果不保留。本地分支比 head 多出的提交数由 `symbolic-ref`、`cat-file -e` 与 `rev-list --count` 得出;当前检出分支不是源分支、本地没有 head 提交或 Git 失败时为 null。
浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes/<session>/local` 与 `dsh-resource://forge-changes/<session>/change/<url>` 指明来源,导航参数可以指定要滚动到的文件。它的正文用 slot store 按标签页保存范围与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。
| 源码 | 职责 |
|---|---|
| [`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/diff.ts`](src/diff.ts) | 补丁解析、把未跟踪文件表示为新增,以及 numstat 记录 |
| [`src/local-diff.ts`](src/local-diff.ts) | 分叉点、本地范围、新版本文本与 `branch` 范围摘要 |
| [`src/client/mount.ts`](src/client/mount.ts) | Remote 挂载、slot 条目与操作结果映射 |
| [`src/client/ChangeRequestCard.tsx`](src/client/ChangeRequestCard.tsx) | 可复用的变更请求卡片 |
| [`src/client/LocalChangesCard.tsx`](src/client/LocalChangesCard.tsx) | 变更卡片 |
| [`src/client/ChangesTab.tsx`](src/client/ChangesTab.tsx) | 变更视图 |
| [`src/client/DiffFile.tsx`](src/client/DiffFile.tsx) | 变更视图中的一个文件 |
| [`src/client/FileLabel.tsx`](src/client/FileLabel.tsx) | 变更文件的标签:文件名在前,目录在后 |
| [`src/client/WorktreeToggle.tsx`](src/client/WorktreeToggle.tsx) | 新会话的工作树选项 |
| [`src/client/WorktreeProgress.tsx`](src/client/WorktreeProgress.tsx) | worktree 创建卡片与 worktree 缺失卡片 |
</details>
-----
<a id="further-exploration"></a>
## 深入探索
- [Forge 子系统](../../../docs/subsystems/forge.zh.md)——变更请求类型与服务参考。
- [Session 绑定](../forge-session/README.zh.md)——变更请求如何绑定到 Session。
- [Worktree 服务](../worktree/README.zh.md)——worktree 如何创建并进入。
-----
<a id="model-experience"></a>
## 模型体验
间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。检查、状态、diff、恢复与面板的其余部分都不会给模型请求增加内容。
#### KV Cache 影响
新 worktree 的通知没有影响,因为它属于 Session 的第一次请求,那时还没有任何缓存前缀。“在项目目录继续”之后的通知与“创建变更请求”发送的消息都追加在缓存前缀之后。
## 已知限制与待办工作
<a id="known-limitations-and-deferred-work"></a>
- **状态只在面板中轮询**——只有本包为该 Session 挂载了某个界面时才会轮询状态;都未挂载时检查结束不会有通知。
- **检查触发时机**——检出只在 Session 的工具结果与目录变化之后检查;Session 之外的改动要等到刷新或下一个工具结果才会出现。
- **保留检出**——面板只在 Session 的第一步之前创建 worktree,不删除已有的检出目录或分支;失败或被取消的尝试可能留下分支或检出。分支名取自 Session id 与随机数字,而不是 Session 标题,因为那时标题还不存在。
- **一轮进行中的消息**——Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留:所请求的 worktree 改为在第一步创建,失败时这一轮结束且不带这条消息;已删除的 worktree 不会被检查,工作目录服务会把 Session 移回项目目录。
- **等待中的消息**——消息等待期间,Agent 以空闲状态处于维护任务中,输入框不提供停止;只有卡片上的操作、Agent 的取消或 Remote 的停止能放行它,等待 Agent 空闲的调用方也会一并等待。
- **恢复**——恢复会从分支重新检出;分支也已删除时,从它开始时的提交重新创建。从未提交的修改随被删除的目录一起丢失。[worktree 服务](../worktree/README.zh.md#use-this-package)校验记录的目标,只移除该目标缺失且未锁定的登记。已有或未恢复完成的目录仍会阻止恢复;请先保存其中的文件并移除目录再重试,或在源项目继续。
- **链接 worktree 项目**——项目本身是链接 worktree 时该选项不可用:在 workspace-write 下它的 Git 目录位于可写根之外,新分支与之后的提交都无法写入。
- **变更范围**——待办:单个提交与上一轮的范围。“分支”范围需要一个默认分支与 HEAD 有共同历史的远端,或 Session 创建的 worktree。
- **大型变更**——本地补丁超过 `maxDiffBytes` 时不显示任何文件;超过托管平台或其提供方大小上限的变更请求 diff 不显示任何文件,只提供“在浏览器中查看”。不自动换行时,左右对比会在栏边截断长行。
- **代码审查页**——待办:在左侧导航中新增一个页面,列出用户在所选提供方上的变更请求;[决策笔记](../../../.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": "Bereich für Änderungsanfragen",
"description": "Zeigt die Änderungsanfragen einer Sitzung in einem Tab der Seitenleiste an und lässt eine neue Sitzung in einem Git-Arbeitsbaum beginnen."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Change request panel",
"description": "Shows a Session's change requests in a sidebar tab and lets a new Session start in a worktree."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Panel de solicitudes de cambio",
"description": "Muestra las solicitudes de cambio de una sesión en una pestaña de la barra lateral y permite que una nueva sesión comience en un árbol de trabajo."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Panneau des demandes de modification",
"description": "Affiche les demandes de modification d'une session dans un onglet de la barre latérale et permet à une nouvelle session de démarrer dans un arbre de travail."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "変更リクエストパネル",
"description": "セッションの変更リクエストをサイドバーのタブに表示し、新しいセッションをワークツリーで開始できるようにします。"
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Painel de solicitações de alteração",
"description": "Mostra as solicitações de alteração de uma sessão em uma guia da barra lateral e permite que uma nova sessão comece em uma árvore de trabalho."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "Панель запросов на изменение",
"description": "Показывает запросы на изменение сеанса во вкладке боковой панели и позволяет начать новый сеанс в рабочем дереве."
}
}
@@ -0,0 +1,6 @@
{
"meta": {
"title": "变更请求面板",
"description": "在侧栏标签页中显示会话的变更请求,并让新会话可以在工作树中开始。"
}
}
@@ -0,0 +1,122 @@
{
"name": "@deepseek-ai/dsh-experimental-client-ui-forge",
"description": "Sidebar panel for a Session's change requests, with a worktree option for new Sessions and its Host Remote",
"version": "0.2.1-rc.3",
"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-experimental-worktree": "workspace:*",
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-timeout": "workspace:*",
"@deepseek-ai/dsh-experimental-forge": "workspace:*",
"@deepseek-ai/dsh-fs": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~",
"@deepseek-ai/dsh-util-values": "workspace:*",
"diff": "^9.0.0",
"zod": "^4.4.3",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
"@deepseek-ai/dsh-agent": "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-util-workspace-path": "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",
"@deepseek-ai/dsh-timeout": "workspace:*"
},
"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,500 @@
/**
* One change request as a card: its title links to the host, a one-line
* summary shows while collapsed, and while expanded one row per fact (checks,
* review, merge state, and the changed lines, which open its changes) above
* the ready-for-review or merge action.
*/
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconCloseOutlineRegular, IconCopyOutlineRegular,
IconListPenOutlineRegular, IconRightUpOutlineRegular, IconUserOutlineRegular, Menu, MenuItemButton, StateDot, Tooltip,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type {
ChangeRequestCheck, ChangeRequestSnapshot, CheckState, ChecksRollup, ForgeFailure, MergeMethod, MergeRequest,
} from '@deepseek-ai/dsh-experimental-forge/types'
import type { ForgePanelChangeRequest } from '../types.ts'
import { formatAgo, formatDuration } from './format.ts'
import { ChangeRequestGlyph, CheckGlyph } from './glyphs.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import { checkCounts, checkDuration, ciSummary, flagOf, numberOf, TONE_LABEL, 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 Session binding omits `remove`, `markReady`, `merge`, and `viewChanges`. */
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
/** Remove the change request from its Session. */
readonly remove?: ((entry: ForgePanelChangeRequest) => void) | undefined
/** Mark a draft ready for review; settles once the outcome is reported. */
readonly markReady?: ((entry: ForgePanelChangeRequest) => Promise<void>) | undefined
/** Merge the change request as `request` describes; settles once the outcome is reported. */
readonly merge?: ((entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>) | undefined
/** Open the changes view of the change request. */
readonly viewChanges?: ((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
/** Active UI language for numeric formatting. */
readonly locale: string
/** Forge panel copy. */
readonly t: Translate
}
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 FAILURE: Record<ForgeFailure['code'], ForgePanelKey> = {
'not-found': 'change.failure.notFound',
'unauthenticated': 'change.failure.unauthenticated',
'too-large': 'change.failure.unavailable',
'unavailable': 'change.failure.unavailable',
}
const ROLLUP: Record<ChecksRollup, CheckState> = {
none: 'neutral',
pending: 'pending',
success: 'success',
failure: 'failure',
}
const CHECK_RANK: Record<CheckState, number> = {
failure: 0, pending: 1, success: 2, neutral: 3, skipped: 3, cancelled: 3,
}
function failureOf(entry: ForgePanelChangeRequest): ForgeFailure | undefined {
return entry.update !== null && 'failure' in entry.update ? entry.update.failure : undefined
}
/** 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 DiffSize({ snapshot, locale }: { readonly snapshot: ChangeRequestSnapshot; readonly locale: string }): ReactNode {
return (
<span className={css.diff}>
<span className={css.added}>{`+${snapshot.additions.toLocaleString(locale)}`}</span>
<span className={css.deleted}>{`−${snapshot.deletions.toLocaleString(locale)}`}</span>
</span>
)
}
/** The changed lines, as a row that opens the changes view when the provider serves the change request. */
function ChangesRow({ entry, snapshot, name, actions, locale, t }: {
readonly entry: ForgePanelChangeRequest
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly actions: ChangeRequestActions
readonly locale: string
readonly t: Translate
}): ReactNode {
const { viewChanges } = actions
const parts = (
<>
<span className={css.rowIcon}><IconListPenOutlineRegular size={16} /></span>
<span className={css.rowLabel}>{t('change.row.changes')}</span>
<span className={css.rowValue}><DiffSize snapshot={snapshot} locale={locale} /></span>
</>
)
if (viewChanges === undefined || entry.ref === null) return <div className={css.row}>{parts}</div>
return (
<button
type="button"
className={clsx(css.row, css.rowButton)}
aria-label={t('change.viewChangesAria', {
name,
added: snapshot.additions.toLocaleString(locale),
deleted: snapshot.deletions.toLocaleString(locale),
})}
onClick={() => { viewChanges(entry) }}
>
{parts}
<IconChevronRightOutlineRegular size={12} className={css.rowChevron} />
</button>
)
}
/** The merged or closed fact with its time, such as 已合并到 main · 2 小时前. */
function finished(snapshot: ChangeRequestSnapshot, now: number, t: Translate): string {
const state = snapshot.state === 'merged' ? t('change.mergedTo', { base: snapshot.baseBranch }) : t('change.state.closed')
const ago = snapshot.closedAt === null ? undefined : formatAgo(snapshot.closedAt, now, t)
return ago === undefined ? state : t('time.finished', { state, time: ago })
}
/** Collapsed status after the number: checks and the most urgent flag, the merge or close time, or a read in progress. */
function Summary({ snapshot, now, t }: {
readonly snapshot: ChangeRequestSnapshot | null
readonly now: number
readonly t: Translate
}): ReactNode {
if (snapshot === null) return <StateDot state="ongoing" size={11} />
if (snapshot.state !== 'open') {
return <span className={css.finished} data-tone={snapshot.state}>{finished(snapshot, now, t)}</span>
}
const ci = ciSummary(snapshot)
const flag = flagOf(snapshot)
return (
<>
<span className={css.ci} data-tone={ci.kind === 'failed' ? 'error' : undefined}>
{ci.kind !== 'none' && <CheckGlyph state={ROLLUP[snapshot.checksRollup]} size={12} />}
{ci.kind === 'failed'
? ci.count > 0 ? t('change.ci.failed', { count: ci.count }) : t('change.ci.failedUnlisted')
: t(`change.ci.${ci.kind}`)}
</span>
{flag !== undefined && <span className={css.flag} data-flag={flag}>{t(`change.flag.${flag}`)}</span>}
</>
)
}
/** Expanded identity after the number: draft state and the head and base branches. */
function Target({ snapshot, t }: { readonly snapshot: ChangeRequestSnapshot; readonly t: Translate }): ReactNode {
return (
<>
{snapshot.state === 'open' && snapshot.draft && <span>{t('change.state.draft')}</span>}
<span className={css.ellipsis}>{`${snapshot.headBranch} → ${snapshot.baseBranch}`}</span>
</>
)
}
function ChecksRow({ snapshot, openLink, t }: {
readonly snapshot: ChangeRequestSnapshot
readonly openLink: (url: string) => void
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
if (snapshot.checks.length === 0) {
return (
<div className={css.row}>
<span className={css.rowIcon}><CheckGlyph state="neutral" /></span>
<span className={css.rowLabel}>{t('change.row.checks')}</span>
<span className={css.rowValue}>{t('change.ci.none')}</span>
</div>
)
}
const counts = checkCounts(snapshot.checks)
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(' · ')
const close = (): void => { setOpen(false) }
const truncated: MenuEntry[] = [{ type: 'label', id: 'truncated', text: t('change.checks.truncated', { count: snapshot.checks.length }) }]
return (
<Menu
open={open}
onClose={close}
portal
dense
className={css.checksAnchor}
listClassName={css.checksList}
anchor={(
<button type="button" className={clsx(css.row, css.rowButton)} aria-haspopup="menu" aria-expanded={open} onClick={() => { setOpen(!open) }}>
<span className={css.rowIcon}><CheckGlyph state={ROLLUP[snapshot.checksRollup]} /></span>
<span className={css.rowLabel}>{t('change.row.checks')}</span>
<span className={css.rowValue} data-tone={counts.failed > 0 ? 'error' : undefined}>
<span className={css.ellipsis}>{summary}</span>
</span>
<IconChevronRightOutlineRegular size={12} className={css.rowChevron} />
</button>
)}
{...snapshot.checksTruncated ? { footer: truncated } : {}}
>
{orderedChecks(snapshot.checks).map((check, index) => {
const { url } = check
return (
<MenuItemButton
// The list is replaced as a whole with each snapshot, and check names can repeat.
key={index}
icon={<CheckGlyph state={check.state} />}
disabled={url === null}
onSelect={url === null ? close : () => {
close()
openLink(url)
}}
>
<span className={css.checkItem}>
<span className={css.ellipsis}>{check.name}</span>
<span className={css.checkTime}>{checkTime(check, t)}</span>
</span>
</MenuItemButton>
)
})}
</Menu>
)
}
/** The merge button and its menu: a title row, one row per method with the chosen one checked, and the confirmation. */
function MergeMenu({ snapshot, name, usual, busy, onMerge, t }: {
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly usual: MergeMethod
readonly busy: boolean
readonly onMerge: (method: MergeMethod, headSha: string) => void
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
const [confirmedHead, setConfirmedHead] = useState(snapshot.headSha)
const [chosen, setChosen] = useState<string>(usual)
const stale = confirmedHead !== snapshot.headSha
// The methods can change between polls; a choice the repository no longer allows falls back to the usual one.
const method = snapshot.mergeMethods.find(entry => entry === chosen) ?? usual
const close = (): void => { setOpen(false) }
const base = snapshot.baseBranch
const items: MenuEntry[] = [
{ type: 'label', id: 'title', text: t('change.mergeTitle', { name, base }) },
...snapshot.mergeMethods.map(entry => ({
id: entry,
label: (
<span className={css.method}>
<span>{t(`change.mergeMethod.${entry}`)}</span>
<span className={css.methodNote}>{t(`change.mergeNote.${entry}`, { base })}</span>
</span>
),
})),
]
return (
<Menu
open={open}
onClose={close}
portal
align="end"
anchor={(
<Button
size="sm"
variant="primary"
className={css.primaryAction}
aria-haspopup="menu"
aria-expanded={open}
disabled={busy}
onClick={() => {
if (!open) setConfirmedHead(snapshot.headSha)
setOpen(!open)
}}
>
{t('change.merge')}
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, open && css.chevronOpen)} />
</Button>
)}
items={items}
selectedId={method}
onSelect={setChosen}
>
<MenuItemButton
separatorBefore
disabled={stale}
onSelect={() => {
close()
onMerge(method, confirmedHead)
}}
>
{t(stale ? 'change.mergeChanged' : 'change.mergeConfirm')}
</MenuItemButton>
</Menu>
)
}
/** The footer with the action of an open change request: mark a draft ready, or merge one the host would merge now. */
function Primary({ entry, snapshot, name, actions, t }: {
readonly entry: ForgePanelChangeRequest
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly actions: ChangeRequestActions
readonly t: Translate
}): ReactNode {
// One request at a time: the action stays disabled until the previous one settles.
const [busy, setBusy] = useState(false)
const run = (action: () => Promise<void>): void => {
setBusy(true)
void action().finally(() => { setBusy(false) })
}
const { markReady, merge } = actions
if (snapshot.state !== 'open') return null
if (snapshot.draft) {
if (markReady === undefined) return null
return (
<div className={css.footer}>
<Button size="sm" variant="outline" className={css.primaryAction} disabled={busy} onClick={() => { run(() => markReady(entry)) }}>
{t('change.markReady')}
</Button>
</div>
)
}
const [usual] = snapshot.mergeMethods
if (!snapshot.mergeReady || usual === undefined || merge === undefined) return null
return (
<div className={css.footer}>
<MergeMenu
snapshot={snapshot}
name={name}
usual={usual}
busy={busy}
onMerge={(method, headSha) => { run(() => merge(entry, { method, headSha })) }}
t={t}
/>
</div>
)
}
function Details({ entry, snapshot, name, now, actions, locale, t }: {
readonly entry: ForgePanelChangeRequest
readonly snapshot: ChangeRequestSnapshot
readonly name: string
readonly now: number
readonly actions: ChangeRequestActions
readonly locale: string
readonly t: Translate
}): ReactNode {
const changes = <ChangesRow entry={entry} snapshot={snapshot} name={name} actions={actions} locale={locale} t={t} />
if (snapshot.state !== 'open') {
return (
<div className={css.rows}>
<div className={css.row}>
<span className={css.rowIcon}><ChangeRequestGlyph tone={snapshot.state} tinted={false} size={16} /></span>
<span className={clsx(css.rowValue, css.rowWide)} data-tone={snapshot.state}>{finished(snapshot, now, t)}</span>
</div>
{changes}
</div>
)
}
const review = t(REVIEW[snapshot.reviewDecision])
const reviewTone = snapshot.reviewDecision === 'changes-requested' ? 'error' : snapshot.reviewDecision === 'approved' ? 'success' : undefined
const merge = snapshot.mergeability === 'mergeable'
? t('change.merge.mergeable')
: snapshot.mergeability === 'conflicting' ? t('change.merge.conflicting', { base: snapshot.baseBranch }) : t('change.merge.unknown')
return (
<div className={css.rows}>
<ChecksRow snapshot={snapshot} openLink={actions.openLink} t={t} />
<div className={css.row}>
<span className={css.rowIcon}><IconUserOutlineRegular size={16} /></span>
<span className={css.rowLabel}>{t('change.row.review')}</span>
<span className={css.rowValue} data-tone={reviewTone}>
{snapshot.unresolvedThreads > 0 ? t('change.review.threads', { review, count: snapshot.unresolvedThreads }) : review}
</span>
</div>
<div className={css.row}>
<span className={css.rowIcon}><ChangeRequestGlyph tone="merged" tinted={false} size={16} /></span>
<span className={css.rowLabel}>{t('change.row.merge')}</span>
<span className={css.rowValue} data-tone={snapshot.mergeability === 'conflicting' ? 'error' : undefined}>{merge}</span>
</div>
{changes}
</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, locale, 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]
const tone = toneOf(snapshot)
const name = number === null ? snapshot?.title ?? url : t('change.number', { number })
// A notice replaces the collapsed summary, so a failed read never shows its stale status beside it.
const meta = expanded
? snapshot === null ? null : <Target snapshot={snapshot} t={t} />
: notice === undefined ? <Summary snapshot={snapshot} now={now} t={t} /> : null
const { remove } = actions
return (
<li className={css.change} data-forge-change={entry.url} data-expanded={expanded ? '' : undefined}>
<div className={css.changeHead}>
<ChangeRequestGlyph tone={tone} size={16} label={t('change.stateAria', { state: t(TONE_LABEL[tone]) })} />
<div className={css.headText}>
<a
className={css.changeTitle}
href={url}
target="_blank"
rel="noreferrer"
onClick={(event) => {
event.preventDefault()
actions.openLink(url)
}}
>
{snapshot?.title ?? url.replace(/^https?:\/\//i, '')}
</a>
{(number !== null || meta !== null) && (
<div className={css.meta}>
{number !== null && <span>{t('change.number', { number })}</span>}
{number !== null && meta !== null && <span aria-hidden="true">·</span>}
{meta}
</div>
)}
</div>
<Tooltip label={t('change.view')} side="top">
<button type="button" className={css.iconButton} aria-label={t('change.view')} onClick={() => { actions.openLink(url) }}>
<IconRightUpOutlineRegular size={14} />
</button>
</Tooltip>
<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>
{remove !== undefined && (
<Tooltip label={t('change.remove')} side="top">
<button type="button" className={css.iconButton} aria-label={t('change.removeAria', { name })} onClick={() => { remove(entry) }}>
<IconCloseOutlineRegular size={14} />
</button>
</Tooltip>
)}
<button
type="button"
className={css.iconButton}
aria-expanded={expanded}
aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })}
onClick={onToggle}
>
<IconChevronDownOutlineRegular size={12} className={clsx(css.chevron, expanded && css.chevronOpen)} />
</button>
</div>
{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 && snapshot !== null && (
<Details entry={entry} snapshot={snapshot} name={name} now={now} actions={actions} locale={locale} t={t} />
)}
{expanded && snapshot !== null && <Primary entry={entry} snapshot={snapshot} name={name} actions={actions} t={t} />}
</li>
)
}
@@ -0,0 +1,610 @@
/* Changes view: the source header, the toolbar, notices, then one scrolling
list of file sections whose headers stick while their lines scroll. */
.root {
--forge-changes-bg: var(--dsw-alias-bg-base);
--forge-fill: var(--dsw-static-neutral-50);
--forge-diff-empty: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--forge-changes-bg);
color: var(--dsw-alias-label-primary);
font-size: 12px;
line-height: 18px;
}
:global(body[data-ds-dark-theme]) .root {
--forge-fill: var(--dsw-static-neutral-850);
}
.header {
display: flex;
flex: none;
align-items: center;
gap: 8px;
box-sizing: border-box;
min-height: 44px;
padding: 0 8px 0 14px;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
.headerIcon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-secondary);
}
.headerTitle {
min-width: 0;
overflow: hidden;
font-size: 13px;
font-weight: 500;
line-height: 20px;
text-overflow: ellipsis;
white-space: nowrap;
}
.headerNumber {
flex: none;
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
}
.headerNote {
min-width: 0;
overflow: hidden;
color: var(--dsw-alias-label-secondary);
text-overflow: ellipsis;
white-space: nowrap;
}
.headerEnd {
flex: none;
margin-left: auto;
}
.toolbar {
display: flex;
flex: none;
align-items: center;
gap: 4px;
box-sizing: border-box;
min-height: 38px;
padding: 0 8px;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
.select {
display: inline-flex;
flex: 0 1 auto;
align-items: center;
gap: 4px;
box-sizing: border-box;
min-width: 0;
height: 28px;
padding: 0 6px 0 8px;
border: 0;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
cursor: pointer;
font: inherit;
white-space: nowrap;
}
.select[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
@media (hover: hover) {
.select:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.select:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
@media (hover: hover) {
.select:disabled:hover {
background: transparent;
}
}
.select svg {
flex: none;
}
.counts {
display: inline-flex;
flex: none;
gap: 6px;
font-family: var(--ds-font-family-code);
}
.totals {
padding: 0 4px;
}
.added {
color: var(--dsw-alias-state-success-primary);
}
.deleted {
color: var(--dsw-alias-state-error-primary);
}
.tools {
display: inline-flex;
flex: none;
align-items: center;
gap: 2px;
margin-left: auto;
}
.tool {
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-secondary);
cursor: pointer;
}
.tool[aria-pressed='true'],
.tool[aria-expanded='true'],
.tool[data-expanded] {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.tool:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
}
.tool:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
.tool svg {
width: 15px;
height: 15px;
}
.tool[aria-pressed='true'] .compareIcon {
transform: rotate(90deg);
}
/* A menu row with a path that shortens and its counts. */
.menuRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-width: 0;
}
.menuPath {
flex: 1;
min-width: 0;
}
.menuList {
min-width: 280px;
max-width: min(420px, 80vw);
}
.notice {
display: flex;
flex: none;
align-items: center;
gap: 8px;
padding: 8px 14px;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
background: var(--forge-fill);
color: var(--dsw-alias-label-secondary);
}
.notice > svg {
flex: none;
}
.noticeAction {
flex: none;
margin-left: auto;
}
.files {
flex: 1;
min-height: 0;
overflow: auto;
}
/* The actions of a failed read, side by side. */
.actions {
display: flex;
gap: 8px;
}
/* Loading, empty, and failed states without changes to show. */
.centered {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
box-sizing: border-box;
padding: 24px;
color: var(--dsw-alias-label-secondary);
font-size: var(--dsh-content-font-size-secondary, 13px);
text-align: center;
}
.file {
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
}
.fileHeader {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
min-height: 36px;
padding: 0 4px 0 8px;
border-bottom: 0.5px solid var(--dsw-alias-border-l3);
background: var(--forge-changes-bg);
}
/* A bare chevron: its rotation shows whether the file is expanded, so it takes no fill in any state. */
.fileToggle {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
width: 20px;
height: 24px;
padding: 0;
border: 0;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-tertiary);
cursor: pointer;
}
@media (hover: hover) {
.fileToggle:hover {
color: var(--dsw-alias-label-primary);
}
}
.file:not([data-expanded]) .fileHeader {
border-bottom: 0;
}
.fileIcon {
flex: none;
}
.filePath {
flex: 1;
min-width: 0;
}
.chevron {
transition: transform 120ms ease;
}
.file:not([data-expanded]) .chevron {
transform: rotate(-90deg);
}
.badge {
flex: none;
max-width: 50%;
overflow: hidden;
padding: 0 6px;
border-radius: 4px;
background: var(--forge-fill);
color: var(--dsw-alias-label-secondary);
font-size: 11px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Lines of one file: one column of grid rows, or two cells per row side by side. */
.code {
overflow-x: auto;
font: var(--dsw-font-markdown-code-block);
}
/* One intrinsic-width track paints every unwrapped unified row across the scrolled width. */
.code:not([data-wrap]):not([data-split]) {
display: grid;
grid-template-columns: minmax(max-content, 100%);
}
.line {
display: grid;
grid-template-columns: 3.5em 3.5em 1.4em minmax(0, 1fr);
box-sizing: border-box;
min-height: 22px;
line-height: 22px;
white-space: pre;
}
.code:not([data-wrap]):not([data-split]) .line {
grid-template-columns: 3.5em 3.5em 1.4em max-content;
}
.pair {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
min-height: 22px;
line-height: 22px;
white-space: pre;
}
.cell {
display: grid;
grid-template-columns: 3.5em minmax(0, 1fr);
box-sizing: border-box;
min-width: 0;
}
.cell + .cell {
border-left: 0.5px solid var(--dsw-alias-border-l3);
}
/* Unwrapped side-by-side cells cut long lines at the column edge. */
.cell .text {
overflow: hidden;
}
.code[data-wrap] .line,
.code[data-wrap] .pair {
white-space: pre-wrap;
}
.code[data-wrap] .text {
overflow-wrap: anywhere;
}
.number {
padding-right: 8px;
color: var(--dsw-alias-label-tertiary);
text-align: right;
user-select: none;
}
.sign {
text-align: center;
user-select: none;
}
.text {
padding: 0 16px 0 6px;
}
.add {
--forge-diff-gutter: var(--dsw-alias-file-diff-added-gutter);
--forge-diff-marker: var(--dsw-alias-file-diff-added-marker);
background: var(--dsw-alias-file-diff-added-bg);
}
.del {
--forge-diff-gutter: var(--dsw-alias-file-diff-deleted-gutter);
--forge-diff-marker: var(--dsw-alias-file-diff-deleted-marker);
background: var(--dsw-alias-file-diff-deleted-bg);
}
.add .number,
.del .number {
background: var(--forge-diff-gutter);
color: var(--forge-diff-marker);
}
.add .sign,
.del .sign {
color: var(--forge-diff-marker);
}
.context .text {
color: var(--dsw-alias-label-secondary);
}
.empty {
background: var(--forge-diff-empty);
}
/* Collapsed unchanged lines; the row expands them. */
/* Collapsed unchanged lines, a collapsed large file, and a file whose lines cannot be drawn share one strip;
only the first two open on a click. */
.gap,
.fileNote {
display: flex;
align-items: center;
gap: 6px;
box-sizing: border-box;
width: 100%;
min-height: 26px;
margin: 0;
padding: 0 12px;
border: 0;
background: var(--forge-fill);
color: var(--dsw-alias-label-tertiary);
font-family: var(--dsw-font-family);
font-size: 12px;
line-height: 18px;
text-align: left;
}
.gap {
cursor: pointer;
}
@media (hover: hover) {
.gap:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
}
.gap:disabled {
cursor: progress;
}
/* The local changes card of the panel: its toggle and refs, the view link and create action, then its files. */
.local {
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);
}
.localHead {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
padding: 8px 12px 2px 6px;
}
.localToggle {
display: inline-flex;
flex: none;
align-items: center;
gap: 6px;
min-height: 26px;
padding: 0 6px;
border: 0;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
cursor: pointer;
font: inherit;
font-weight: 500;
}
@media (hover: hover) {
.localToggle:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.localToggle svg {
color: var(--dsw-alias-label-tertiary);
transition: transform 120ms ease;
}
.localToggle[aria-expanded='true'] svg {
transform: rotate(180deg);
}
.localRefs {
min-width: 0;
margin-left: auto;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
font-family: var(--ds-font-family-code);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.localRow {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 10px 10px 12px;
font-size: 12px;
}
.localView {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
min-height: 28px;
margin-left: -6px;
padding: 0 6px;
border: 0;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: inherit;
}
@media (hover: hover) {
.localView:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.localCreate {
flex: none;
margin-left: auto;
}
.localFiles {
margin: 0;
padding: 4px 6px 8px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
list-style: none;
}
.localFile {
display: flex;
align-items: center;
gap: 8px;
box-sizing: border-box;
width: 100%;
min-height: 28px;
padding: 0 6px;
border: 0;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
cursor: pointer;
font: inherit;
font-size: 12px;
text-align: left;
}
@media (hover: hover) {
.localFile:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.localFile .menuPath {
flex: 1;
}
.localView:focus-visible,
.localToggle:focus-visible,
.fileToggle:focus-visible,
.localFile:focus-visible,
.select:focus-visible,
.gap:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: -2px;
}
@@ -0,0 +1,563 @@
/**
* Body of the changes tab: the local changes of the Session's checkout in a
* chosen scope, or the changes of one of its change requests, as one list of
* file sections under a toolbar that jumps between files and switches the
* layout. The view reads the changes when it opens, on each navigation, on
* refresh, and when the change request's head or the checkout's `branch`
* scope summary changes; a read keeps the previous changes on screen until it settles.
*/
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
Button, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconCompareSplitOutlineRegular, IconInfoOutlineRegular,
IconListPenOutlineRegular, IconNowrapFillRegular, IconRightUpOutlineRegular, IconWrapFillRegular, Menu, StateDot, Tooltip,
type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import { fileAddressFor, pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import type {
ForgeChangeRequestDiff, ForgeDiffFile, ForgeLocalDiff, ForgeLocalScope, ForgePanelChangeRequest, ForgePanelState,
} from '../types.ts'
import { parseChangesAddress, type ChangesSource } from './changes-address.ts'
import type { createChangesStore } from './changes-store.ts'
import { Counts, DiffFile, type DiffFileAction } from './DiffFile.tsx'
import { drawnLines, matchesHunks, textLines } from './diff-rows.ts'
import { FileLabel } from './FileLabel.tsx'
import { ChangeRequestGlyph } from './glyphs.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import { RefreshGlyph } from './RefreshGlyph.tsx'
import type { PanelSnapshot } from './source.ts'
import { numberOf, openChangeRequestOf, TONE_LABEL, toneOf } from './status.ts'
import css from './ChangesTab.module.css'
type Translate = PropsLocale<typeof NS>['t']
/** Kind of a local scope. */
type ScopeKind = ForgeLocalScope['kind']
/** Business face of the changes tab body. */
export interface ChangesTabInjected {
/** Panel snapshot of the Session; the renderer binds it as `usePanel`. */
readonly hooks: { readonly panel: HostObservable<PanelSnapshot> }
/** Open the panel stream again after it ended. */
readonly reconnect: () => void
/** Read the local changes of one scope; `signal` aborts the read. */
readonly localDiff: (scope: ForgeLocalScope, signal: AbortSignal) => Promise<RemoteResult<ForgeLocalDiff>>
/** Read the new side of one file in a local scope. */
readonly localText: (scope: ForgeLocalScope, path: string) => Promise<RemoteResult<string>>
/** Read the changes of a change request by its binding URL; `signal` aborts the read. */
readonly changeRequestDiff: (url: string, signal: AbortSignal) => Promise<RemoteResult<ForgeChangeRequestDiff>>
/** Read one file of a change request at a commit. */
readonly changeRequestText: (url: string, commit: string, path: string) => Promise<RemoteResult<string>>
/** Open a URL outside the app. */
readonly openLink: (url: string) => void
/** Copy a path to the clipboard and report the outcome. */
readonly copyPath: (path: string) => void
/** Ask the Session's Agent to create a change request for its changes, reporting the outcome. */
readonly createChangeRequest: () => void
/** Show a failure notice. */
readonly report: (key: ForgePanelKey, params?: Readonly<Record<string, string>>) => void
}
/** Full props of the changes tab body. */
export type ChangesTabProps =
Pick<PropsRuntime<'sidebar.right.pane.tab'>, 'useTabInfo' | 'sessionId'>
& PropsStore<ReturnType<typeof createChangesStore>>
& InjectFace<ChangesTabInjected>
& PropsLocale<typeof NS>
/** Changes of either source as the view draws them. */
type ChangesData =
| {
readonly kind: 'local'
readonly files: readonly ForgeDiffFile[]
/** Scope the files were read in. */
readonly scope: ScopeKind
/** Untracked files left out. */
readonly omitted: number
}
| {
readonly kind: 'change'
readonly files: readonly ForgeDiffFile[]
/** Binding URL of the change request. */
readonly url: string
/** Head commit the files describe, which supplies unchanged lines. */
readonly headSha: string
/** Commits the checkout's branch has beyond the head, or null. */
readonly localAhead: number | null
/** Web page of each file at the head. */
readonly fileUrls: Readonly<Record<string, string>>
}
/** Unchanged lines read for one loaded diff: each file's new side and its expanded gaps. */
interface Expansion {
readonly data: ChangesData | null
readonly texts: Readonly<Record<string, readonly string[] | 'loading'>>
readonly open: Readonly<Record<string, ReadonlySet<number>>>
}
/** Files whose hunks draw more lines show the large-file notice until revealed. */
const LARGE_FILE_LINES = 1000
/** Lines the files above may change before a file starts collapsed. */
const OPEN_LINES = 5000
const NO_GAPS: ReadonlySet<number> = new Set()
const SCOPE_LABEL: Record<ScopeKind, ForgePanelKey> = {
branch: 'diff.scope.branch',
uncommitted: 'diff.scope.uncommitted',
unstaged: 'diff.scope.unstaged',
staged: 'diff.scope.staged',
}
const SCOPE_NOTE: Record<Exclude<ScopeKind, 'branch'>, ForgePanelKey> = {
uncommitted: 'diff.scopeNote.uncommitted',
unstaged: 'diff.scopeNote.unstaged',
staged: 'diff.scopeNote.staged',
}
/** Failures with their own message; any other failure offers a retry. */
const FAILURE: Partial<Record<string, ForgePanelKey>> = {
'forgePanel/not-a-checkout': 'checkout.none',
'forgePanel/no-fork-point': 'diff.noForkPoint',
'forgePanel/diff-too-large': 'diff.tooLarge',
}
async function readChanges(
source: ChangesSource,
scope: ScopeKind,
reads: Pick<ChangesTabInjected, 'localDiff' | 'changeRequestDiff'>,
signal: AbortSignal,
): Promise<RemoteResult<ChangesData>> {
if (source.kind === 'local') {
const result = await reads.localDiff({ kind: scope }, signal)
if (!result.ok) return result
return { ok: true, value: { kind: 'local', files: result.value.files, scope, omitted: result.value.omitted } }
}
const result = await reads.changeRequestDiff(source.url, signal)
if (!result.ok) return result
const { files, headSha, localAhead, fileUrls } = result.value
return { ok: true, value: { kind: 'change', files, url: source.url, headSha, localAhead, fileUrls } }
}
/** The files that start expanded: every file until those above it change more than {@link OPEN_LINES} lines. */
function initiallyOpen(files: readonly ForgeDiffFile[]): ReadonlySet<string> {
const open = new Set<string>()
let above = 0
for (const file of files) {
if (above > OPEN_LINES) break
open.add(file.path)
above += drawnLines(file)
}
return open
}
/** What a rename badge names: the old file name, or the old path when only the directory changed. */
function renamedFrom(previous: string, path: string): string {
const name = pathPartsOf(previous).name
return name === pathPartsOf(path).name ? previous : name
}
/** The hover text of a file's path: both paths of a renamed file, else the path. */
function pathTooltipOf(file: ForgeDiffFile, t: Translate): string {
return file.status === 'renamed' && file.previousPath !== null ? t('diff.renamedPath', { from: file.previousPath, path: file.path }) : file.path
}
/** The badge after a path: how the file changed, and whether a branch view's file is untracked. */
function badgeOf(file: ForgeDiffFile, data: ChangesData, t: Translate): string {
const parts: string[] = []
if (file.status === 'added') parts.push(t('diff.newFile'))
else if (file.status === 'deleted') parts.push(t('diff.deletedFile'))
else if (file.status === 'renamed' && file.previousPath !== null) parts.push(t('diff.renamed', { from: renamedFrom(file.previousPath, file.path) }))
if (file.untracked && data.kind === 'local' && data.scope === 'branch') parts.push(t('diff.badgeUncommitted'))
return parts.join(' · ')
}
function ChangeHeader({ url, entry, openLink, t }: {
readonly url: string
readonly entry: ForgePanelChangeRequest | undefined
readonly openLink: (url: string) => void
readonly t: Translate
}): ReactNode {
const snapshot = entry?.snapshot ?? null
const number = numberOf(entry ?? { ref: null, url })
const tone = toneOf(snapshot)
const href = snapshot === null ? url : snapshot.url
return (
<div className={css.header}>
<ChangeRequestGlyph tone={tone} label={t('change.stateAria', { state: t(TONE_LABEL[tone]) })} />
<span className={css.headerTitle}>{snapshot === null ? url : snapshot.title}</span>
{number !== null && <span className={css.headerNumber}>{t('change.number', { number })}</span>}
<Tooltip label={t('change.view')} side="bottom">
<button type="button" className={clsx(css.tool, css.headerEnd)} aria-label={t('change.view')} onClick={() => { openLink(href) }}>
<IconRightUpOutlineRegular size={14} />
</button>
</Tooltip>
</div>
)
}
function LocalHeader({ state, scope, createChangeRequest, t }: {
readonly state: ForgePanelState | null
readonly scope: ScopeKind
readonly createChangeRequest: () => void
readonly t: Translate
}): ReactNode {
const base = state?.local?.base ?? null
const note = scope !== 'branch' ? t(SCOPE_NOTE[scope]) : base === null ? '' : t('diff.scopeNote.branch', { base })
const creatable = state !== null && state.repository !== null && openChangeRequestOf(state) === undefined
return (
<div className={css.header}>
<span className={css.headerIcon}><IconListPenOutlineRegular size={15} /></span>
<span className={css.headerNote}>{note}</span>
{creatable && (
<Button size="sm" variant="outline" className={css.headerEnd} onClick={createChangeRequest}>{t('changes.local.create')}</Button>
)}
</div>
)
}
function ScopeMenu({ scope, choose, t }: {
readonly scope: ScopeKind
readonly choose: (scope: ScopeKind) => void
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
const items: MenuEntry[] = [
{ id: 'uncommitted', label: t(SCOPE_LABEL.uncommitted) },
{ id: 'unstaged', label: t(SCOPE_LABEL.unstaged) },
{ id: 'staged', label: t(SCOPE_LABEL.staged) },
{ type: 'separator', id: 'separator' },
{ id: 'branch', label: t(SCOPE_LABEL.branch) },
]
return (
<Menu
open={open}
portal
align="start"
dense
autoFocus
selectedId={scope}
items={items}
onClose={() => { setOpen(false) }}
onSelect={(id) => {
setOpen(false)
choose(id as ScopeKind)
}}
anchor={(
<button type="button" className={css.select} aria-haspopup="menu" aria-expanded={open} data-forge-scope={scope}
onClick={() => { setOpen(value => !value) }}>
{t(SCOPE_LABEL[scope])}
<IconChevronDownOutlineRegular size={12} />
</button>
)}
/>
)
}
function FilesMenu({ files, jump, t }: {
readonly files: readonly ForgeDiffFile[]
readonly jump: (path: string) => void
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
portal
align="start"
dense
autoFocus
listClassName={css.menuList}
items={files.map(file => ({
id: file.path,
label: <span className={css.menuRow}><FileLabel path={file.path} className={css.menuPath} /><Counts file={file} t={t} /></span>,
}))}
onClose={() => { setOpen(false) }}
onSelect={(path) => {
setOpen(false)
jump(path)
}}
anchor={(
<button type="button" className={css.select} aria-haspopup="menu" aria-expanded={open} disabled={files.length === 0}
onClick={() => { setOpen(value => !value) }}>
{t('diff.allFiles', { count: files.length })}
<IconChevronDownOutlineRegular size={12} />
</button>
)}
/>
)
}
function Notice({ children, action }: { readonly children: ReactNode; readonly action?: ReactNode }): ReactNode {
return (
<div className={css.notice} role="status">
<IconInfoOutlineRegular size={14} />
<span>{children}</span>
{action}
</div>
)
}
/**
* Render the changes tab of one Session.
* @param props - tab information, the tab's choices, the panel snapshot hook, reads and actions, and copy.
* @returns the view body.
*/
export function ChangesTab({
useTabInfo, sessionId, useStore, actions, usePanel, reconnect, localDiff, localText, changeRequestDiff, changeRequestText,
openLink, copyPath, createChangeRequest, report, t,
}: ChangesTabProps): ReactNode {
const { tab } = useTabInfo()
const { navigation, signal } = tab
const target = useMemo(() => parseChangesAddress(tab.contentId), [tab.contentId])
if (target === undefined) throw new Error(`forge-panel: not a changes address "${tab.contentId}"`)
const source = target.source
const choices = useStore(store => store.byTab[tab.id])
const [wanted, setWanted] = useState<string | null>(null)
const [chosen, setChosen] = useState<Readonly<Record<string, boolean>>>({})
// Each navigation applies once; its file, if it names one, opens and scrolls into view.
useEffect(() => {
if (choices?.navigated === navigation.revision) return
actions.navigated(tab.id, navigation.revision)
const path = navigation.params?.path
if (path === undefined) return
setChosen(value => ({ ...value, [path]: true }))
setWanted(path)
}, [choices, navigation.revision, navigation.params, actions, tab.id])
// The tab's choices end with its record.
useEffect(() => {
const unmounted = new AbortController()
signal.addEventListener('abort', () => { actions.forget(tab.id) }, { once: true, signal: unmounted.signal })
return () => { unmounted.abort() }
}, [signal, actions, tab.id])
const [attempt, setAttempt] = useState(0)
useEffect(() => tab.actions.bindCommands({ refresh: () => { setAttempt(value => value + 1) } }), [tab.actions])
const snapshot = usePanel(current => current)
const state = snapshot.state
const scope = choices?.scope ?? 'branch'
const split = choices?.split ?? true
const wrap = choices?.wrap ?? true
const entry = source.kind === 'change' ? state?.changeRequests.find(item => item.url === source.url) : undefined
// A read starts once the panel state arrives and again when what it describes moves. A change request's read
// also waits for its first status, which names the head, unless no provider serves it or its first poll failed.
const pending = entry !== undefined && entry.ref !== null && entry.update === null
const head = entry?.snapshot?.headSha ?? (pending ? null : '')
const version = state === null ? null : source.kind === 'local' ? `${scope}\n${JSON.stringify(state.local)}` : head
const dataKey = source.kind === 'local' ? scope : 'change'
const [loaded, setLoaded] = useState<{ readonly key: string; readonly data: ChangesData } | null>(null)
const [failure, setFailure] = useState<{ readonly key: string; readonly code: string } | null>(null)
// Names the read the inputs ask for; it is in flight until `finished` matches it.
const read = version === null ? null : `${dataKey}\n${navigation.revision}\n${attempt}\n${version}`
const [finished, setFinished] = useState<string | null>(null)
useEffect(() => {
if (read === null) return
const controller = new AbortController()
void readChanges(source, scope, { localDiff, changeRequestDiff }, controller.signal).then((result) => {
if (controller.signal.aborted) return
setFinished(read)
if (result.ok) {
setLoaded({ key: dataKey, data: result.value })
setFailure(null)
} else {
setFailure({ key: dataKey, code: result.error.code })
}
})
return () => { controller.abort() }
}, [read, source, scope, dataKey, localDiff, changeRequestDiff])
const data = loaded !== null && loaded.key === dataKey ? loaded.data : null
// The centered loader marks a first read; the toolbar's glyph turns only while shown changes are read again.
const rereading = read !== null && finished !== read && data !== null
const failed = failure !== null && failure.key === dataKey ? failure.code : null
const [expansion, setExpansion] = useState<Expansion>({ data: null, texts: {}, open: {} })
const current = expansion.data === data ? expansion : { data, texts: {}, open: {} }
const latest = useRef(data)
useEffect(() => { latest.current = data }, [data])
const [revealed, setRevealed] = useState<ReadonlySet<string>>(new Set())
const initial = useMemo(() => initiallyOpen(data === null ? [] : data.files), [data])
const sections = useRef(new Map<string, HTMLElement>())
useLayoutEffect(() => {
if (wanted === null || data === null) return
sections.current.get(wanted)?.scrollIntoView({ block: 'start' })
setWanted(null)
}, [wanted, data])
const refresh = (): void => { setAttempt(value => value + 1) }
const root = state !== null && state.checkout.state === 'ready' ? state.checkout.checkout.root : null
// A failure with its own message offers no retry; a change request's failure also offers its web page.
const message = failed === null ? undefined : FAILURE[failed]
const changeUrl = source.kind === 'change' ? entry?.snapshot?.url ?? source.url : null
const retry = message === undefined
/** Apply a change to the unchanged lines of `owner`, unless newer changes replaced it. */
const update = (owner: ChangesData, change: (value: Expansion) => Expansion): void => {
setExpansion((previous) => {
if (latest.current !== owner) return previous
return change(previous.data === owner ? previous : { data: owner, texts: {}, open: {} })
})
}
const expand = (owner: ChangesData, file: ForgeDiffFile, index: number): void => {
const path = file.path
update(owner, value => ({ ...value, open: { ...value.open, [path]: new Set([...value.open[path] ?? [], index]) } }))
if (current.texts[path] !== undefined) return
update(owner, value => ({ ...value, texts: { ...value.texts, [path]: 'loading' } }))
const reading = owner.kind === 'local' ? localText({ kind: owner.scope }, path) : changeRequestText(owner.url, owner.headSha, path)
void reading.then((result) => {
const lines = result.ok ? textLines(result.value) : undefined
if (lines !== undefined && matchesHunks(lines, file.hunks)) {
update(owner, value => ({ ...value, texts: { ...value.texts, [path]: lines } }))
return
}
if (result.ok) report('toast.expandStale')
else report('toast.expandFailed', { message: result.error.message })
update(owner, (value) => {
const { [path]: _text, ...texts } = value.texts
const { [path]: _open, ...open } = value.open
return { ...value, texts, open }
})
})
}
const fileActions = (owner: ChangesData, file: ForgeDiffFile): DiffFileAction[] => {
const list: DiffFileAction[] = [{ label: 'diff.copyPath', run: () => { copyPath(file.path) } }]
if (file.status === 'deleted') return list
if (owner.kind === 'local') {
if (root !== null) {
list.push({ label: 'diff.openFileInSidebar', run: () => { tab.actions.openResource(fileAddressFor(sessionId, undefined, `${root}/${file.path}`)) } })
}
} else {
const url = owner.fileUrls[file.path]
if (url !== undefined) list.push({ label: 'diff.openFileInBrowser', run: () => { openLink(url) } })
}
return list
}
let body: ReactNode
if (data === null) {
if (state === null && snapshot.disconnected) {
body = (
<div className={css.centered}>
<span>{t('stream.failed')}</span>
<Button size="sm" variant="outline" onClick={reconnect}>{t('retry')}</Button>
</div>
)
} else if (failed === null) {
body = <div className={css.centered}><StateDot state="ongoing" /></div>
} else {
body = (
<div className={css.centered} role="status">
<span>{t(message ?? 'diff.error')}</span>
{(retry || changeUrl !== null) && (
<div className={css.actions}>
{retry && <Button size="sm" variant="outline" onClick={refresh}>{t('retry')}</Button>}
{changeUrl !== null && (
<Button size="sm" variant="outline" onClick={() => { openLink(changeUrl) }}>{t('diff.viewInBrowser')}</Button>
)}
</div>
)}
</div>
)
}
} else if (data.files.length === 0) {
body = <div className={css.centered}>{t('diff.empty')}</div>
} else {
body = (
<div className={css.files}>
{data.files.map((file) => {
const path = file.path
return (
<DiffFile
key={path}
file={file}
expanded={chosen[path] ?? initial.has(path)}
onToggle={() => { setChosen(value => ({ ...value, [path]: !(value[path] ?? initial.has(path)) })) }}
large={drawnLines(file) > LARGE_FILE_LINES}
revealed={revealed.has(path)}
onReveal={() => { setRevealed(value => new Set([...value, path])) }}
text={current.texts[path]}
open={current.open[path] ?? NO_GAPS}
onExpand={(index) => { expand(data, file, index) }}
split={split}
wrap={wrap}
badge={badgeOf(file, data, t)}
tooltip={pathTooltipOf(file, t)}
actions={fileActions(data, file)}
sectionRef={(element) => {
if (element === null) sections.current.delete(path)
else sections.current.set(path, element)
}}
t={t}
/>
)
})}
</div>
)
}
const files = data === null ? [] : data.files
// The toolbar collapses every file while any is expanded, as the files on screen show, and expands every file otherwise.
const anyExpanded = files.some(file => chosen[file.path] ?? initial.has(file.path))
const expansionLabel = t(anyExpanded ? 'diff.collapseAll' : 'diff.expandAll')
let additions = 0
let deletions = 0
for (const file of files) {
additions += file.additions
deletions += file.deletions
}
return (
<div className={css.root} data-forge-changes={source.kind}>
{source.kind === 'local'
? <LocalHeader state={state} scope={scope} createChangeRequest={createChangeRequest} t={t} />
: <ChangeHeader url={source.url} entry={entry} openLink={openLink} t={t} />}
<div className={css.toolbar}>
{source.kind === 'local' && <ScopeMenu scope={scope} choose={(next) => { actions.scoped(tab.id, next) }} t={t} />}
<FilesMenu
files={files}
jump={(path) => {
setChosen(value => ({ ...value, [path]: true }))
setWanted(path)
}}
t={t}
/>
{files.length > 0 && <span className={css.totals}><Counts file={{ additions, deletions, binary: false }} t={t} /></span>}
<span className={css.tools}>
<Tooltip label={t('diff.refresh')} side="bottom">
<button type="button" className={css.tool} aria-label={t('diff.refresh')} onClick={refresh}>
<RefreshGlyph busy={rereading} size={16} />
</button>
</Tooltip>
<Tooltip label={expansionLabel} side="bottom">
<button type="button" className={css.tool} data-expanded={anyExpanded ? '' : undefined} aria-label={expansionLabel}
disabled={files.length === 0}
onClick={() => { setChosen(Object.fromEntries(files.map(file => [file.path, !anyExpanded]))) }}>
<IconChevronsUpDownOutlineRegular />
</button>
</Tooltip>
<Tooltip label={t(split ? 'diff.unified' : 'diff.split')} side="bottom">
<button type="button" className={css.tool} aria-pressed={split} aria-label={t('diff.splitAria')}
onClick={() => { actions.toggledSplit(tab.id) }}>
<IconCompareSplitOutlineRegular className={css.compareIcon} />
</button>
</Tooltip>
<Tooltip label={t(wrap ? 'diff.nowrap' : 'diff.wrap')} side="bottom">
<button type="button" className={css.tool} aria-pressed={wrap} aria-label={t('diff.wrapAria')}
onClick={() => { actions.toggledWrap(tab.id) }}>
{wrap ? <IconNowrapFillRegular /> : <IconWrapFillRegular />}
</button>
</Tooltip>
</span>
</div>
{data !== null && failed !== null && (
<Notice action={retry && <Button size="sm" variant="outline" className={css.noticeAction} onClick={refresh}>{t('retry')}</Button>}>
{t(message ?? 'diff.error')}
</Notice>
)}
{data?.kind === 'change' && data.localAhead !== null && data.localAhead > 0 && (
<Notice>{t('diff.stale', { count: data.localAhead })}</Notice>
)}
{data?.kind === 'local' && data.omitted > 0 && <Notice>{t('diff.omitted', { count: data.omitted })}</Notice>}
{body}
</div>
)
}
@@ -0,0 +1,28 @@
/** Chip title of a changes tab: the changes icon and the view's name in the current language. */
import type { ReactNode } from 'react'
import { IconListPenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import { changesTitle, parseChangesAddress } from './changes-address.ts'
import type { NS } from './locales.ts'
import css from './ForgeTab.module.css'
/** Props of the changes tab title. */
export type ChangesTabTitleProps = Pick<PropsRuntime<'sidebar.right.pane.tab.title'>, 'useTabInfo'> & PropsLocale<typeof NS>
/**
* Render the chip title of a changes tab; an address this package did not mint keeps the captured title.
* @param props - tab information and forge panel copy.
* @returns the icon followed by the title.
*/
export function ChangesTabTitle({ useTabInfo, t }: ChangesTabTitleProps): ReactNode {
const { tab } = useTabInfo()
const target = parseChangesAddress(tab.contentId)
return (
<>
<span className={css.titleIcon}><IconListPenOutlineRegular size={16} /></span>
<span className={css.titleText}>{target === undefined ? tab.title : changesTitle(target.source, t)}</span>
</>
)
}
@@ -0,0 +1,259 @@
/**
* One file of the changes view: a sticky header with the path, badges, line
* counts, and file actions, then its hunks unified or side by side with the
* unchanged lines between them collapsed into rows that expand them.
*/
import { useMemo, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
FileTypeIcon, IconChevronDownOutlineRegular, IconChevronsUpDownOutlineRegular, IconEllipsisOutlineRegular,
languageForPath, Menu, MenuItemButton, useCodeHighlighter, type CodeHighlighter, type HighlightSpan,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgeDiffFile } from '../types.ts'
import { segmentsOf, splitRows, type DiffRow, type SplitCell, type SplitRow } from './diff-rows.ts'
import { FileLabel } from './FileLabel.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import css from './ChangesTab.module.css'
type Translate = PropsLocale<typeof NS>['t']
/** One action of a file's menu. */
export interface DiffFileAction {
/** Dictionary key of the row label. */
readonly label: ForgePanelKey
/** Runs the action. */
readonly run: () => void
}
/** New side of a file: its lines once read, `loading` while a read runs, or undefined before any. */
export type DiffFileText = readonly string[] | 'loading' | undefined
/** Props of one file section. */
export interface DiffFileProps {
/** The file and its hunks. */
readonly file: ForgeDiffFile
/** Whether the section shows its lines. */
readonly expanded: boolean
/** Expand or collapse the section. */
readonly onToggle: () => void
/** Whether the file shows the large-file notice instead of its lines until revealed. */
readonly large: boolean
/** Whether the user revealed a large file. */
readonly revealed: boolean
/** Show a large file anyway. */
readonly onReveal: () => void
/** The file's new side, which supplies collapsed unchanged lines. */
readonly text: DiffFileText
/** Indexes of the expanded gaps. */
readonly open: ReadonlySet<number>
/** Expand one gap. */
readonly onExpand: (index: number) => void
/** Whether deletions and additions show in two columns; a file with only additions or only deletions keeps one. */
readonly split: boolean
/** Whether long lines wrap. */
readonly wrap: boolean
/** Badge text after the path, or empty for none. */
readonly badge: string
/** Hover text of the path: both paths of a renamed file, else the path. */
readonly tooltip: string
/** Rows of the file's menu. */
readonly actions: readonly DiffFileAction[]
/** Receives the section element, which scrolling to the file targets. */
readonly sectionRef: (element: HTMLElement | null) => void
/** Forge panel copy. */
readonly t: Translate
}
/**
* Render added and deleted line counts, or the binary label for binary content.
* @param props - the counts and copy.
* @returns the counts.
*/
export function Counts({ file, t }: {
readonly file: Pick<ForgeDiffFile, 'additions' | 'deletions' | 'binary'>
readonly t: Translate
}): ReactNode {
if (file.binary) return <span>{t('diff.binaryLabel')}</span>
return (
<span className={css.counts}>
<span className={css.added}>{`+${file.additions.toLocaleString('en-US')}`}</span>
<span className={css.deleted}>{`−${file.deletions.toLocaleString('en-US')}`}</span>
</span>
)
}
const SIGN: Record<DiffRow['kind'], string> = { add: '+', del: '-', context: '' }
/** Highlighted tokens of one side's lines by line number, or undefined while the grammar is unavailable. */
type SideSpans = ReadonlyMap<number, readonly HighlightSpan[]> | undefined
/** A segment ready to draw: collapsed lines, or rows with the tokens of each side. */
type Prepared =
| { readonly kind: 'gap'; readonly index: number; readonly count: number | null }
| { readonly kind: 'rows'; readonly rows: readonly DiffRow[]; readonly old: SideSpans; readonly new: SideSpans }
/** Highlight one side of a run of rows as one fragment, so tokens spanning lines keep their state. */
function sideSpans(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): SideSpans {
const numbered: { readonly no: number; readonly text: string }[] = []
for (const row of rows) {
const no = row[side]
if (no !== null) numbered.push({ no, text: row.text })
}
if (numbered.length === 0) return new Map()
const highlighted = highlighter(numbered.map(line => line.text).join('\n'))
if (highlighted === undefined) return undefined
// Shiki omits a final empty token row, so a final empty line gets no tokens.
return new Map(numbered.map((line, index) => [line.no, highlighted[index] ?? []]))
}
function Code({ text, spans }: { readonly text: string; readonly spans: readonly HighlightSpan[] | undefined }): ReactNode {
return (
<span className={css.text} data-diff-code={spans === undefined ? undefined : ''}>
{spans === undefined ? text : spans.map((span, index) => <span key={index} style={span.style}>{span.text}</span>)}
</span>
)
}
function Cell({ cell, spans }: { readonly cell: SplitCell | null; readonly spans: SideSpans }): ReactNode {
if (cell === null) return <span className={clsx(css.cell, css.empty)} />
return (
<span className={clsx(css.cell, css[cell.kind])}>
<span className={css.number}>{cell.no}</span>
<Code text={cell.text} spans={spans?.get(cell.no)} />
</span>
)
}
/** Whether a side-by-side row repeats an unchanged line or shows a change. */
function pairKind(pair: SplitRow): 'context' | 'change' {
return pair.left !== null && pair.left.kind === 'context' ? 'context' : 'change'
}
/** The hunks of one file with their collapsed and expanded unchanged lines. */
function Lines({ file, text, open, onExpand, split, wrap, t }: Pick<DiffFileProps, 'file' | 'text' | 'open' | 'onExpand' | 'split' | 'wrap' | 't'>): ReactNode {
const lines = typeof text === 'object' ? text : undefined
const columns = split && file.additions > 0 && file.deletions > 0
const highlighter = useCodeHighlighter(languageForPath(file.path))
const prepared = useMemo(() => segmentsOf(file, lines, open).map((segment): Prepared => segment.kind === 'gap'
? segment
: { ...segment, old: sideSpans(segment.rows, 'old', highlighter), new: sideSpans(segment.rows, 'new', highlighter) }), [file, lines, open, highlighter])
return (
<div className={css.code} data-split={columns ? '' : undefined} data-wrap={wrap ? '' : undefined}>
{prepared.map((segment, at) => {
if (segment.kind === 'gap') {
return (
<button
key={`gap-${segment.index}`}
type="button"
className={css.gap}
disabled={text === 'loading'}
data-forge-diff-gap={segment.index}
onClick={() => { onExpand(segment.index) }}
>
<IconChevronsUpDownOutlineRegular size={12} />
{segment.count === null ? t('diff.unchangedRest') : t('diff.unchanged', { count: segment.count.toLocaleString('en-US') })}
</button>
)
}
if (columns) {
return splitRows(segment.rows).map((pair, index) => (
<div key={`${at}-${index}`} className={css.pair} data-diff-pair={pairKind(pair)}>
<Cell cell={pair.left} spans={segment.old} />
<Cell cell={pair.right} spans={segment.new} />
</div>
))
}
return segment.rows.map((row, index) => (
<div key={`${at}-${index}`} className={clsx(css.line, css[row.kind])} data-diff-line={row.kind}>
<span className={css.number}>{row.old ?? ''}</span>
<span className={css.number}>{row.new ?? ''}</span>
<span className={css.sign}>{SIGN[row.kind]}</span>
<Code text={row.text} spans={row.kind === 'del' ? segment.old?.get(row.old) : segment.new?.get(row.new)} />
</div>
))
})}
</div>
)
}
function Body(props: DiffFileProps): ReactNode {
const { file, large, revealed, onReveal, t } = props
if (file.binary) return <p className={css.fileNote}>{t('diff.binary')}</p>
if (file.oversized) return <p className={css.fileNote}>{t('diff.fileTooLarge')}</p>
if (file.hunks.length === 0) return null
if (large && !revealed) {
// A large file starts as one collapsed strip, drawn and opened like the collapsed unchanged lines.
return (
<button type="button" className={css.gap} onClick={onReveal}>
<IconChevronsUpDownOutlineRegular size={12} />
{t('diff.largeCollapsed', { count: (file.additions + file.deletions).toLocaleString('en-US') })}
</button>
)
}
return <Lines {...props} />
}
/**
* Render one file of a diff.
* @param props - the file, its section and gap state, layout choices, menu actions, and copy.
* @returns the file section.
*/
export function DiffFile(props: DiffFileProps): ReactNode {
const { file, expanded, onToggle, badge, tooltip, actions, sectionRef, t } = props
const [menuOpen, setMenuOpen] = useState(false)
const name = file.path.slice(file.path.lastIndexOf('/') + 1)
return (
<section ref={sectionRef} className={css.file} data-forge-diff-file={file.path} data-expanded={expanded ? '' : undefined}>
<div className={css.fileHeader}>
<button
type="button"
className={css.fileToggle}
aria-expanded={expanded}
aria-label={t(expanded ? 'change.collapse' : 'change.expand', { name })}
onClick={onToggle}
>
<IconChevronDownOutlineRegular size={14} className={css.chevron} />
</button>
<FileTypeIcon path={file.path} size={16} className={css.fileIcon} />
<FileLabel path={file.path} tooltip={tooltip} className={css.filePath} />
{badge !== '' && <span className={css.badge}>{badge}</span>}
{!file.binary && <Counts file={file} t={t} />}
<Menu
open={menuOpen}
portal
align="end"
dense
autoFocus
onClose={() => { setMenuOpen(false) }}
anchor={(
<button
type="button"
className={css.tool}
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={t('diff.moreActions', { name })}
onClick={() => { setMenuOpen(value => !value) }}
>
<IconEllipsisOutlineRegular />
</button>
)}
>
{actions.map(action => (
<MenuItemButton
key={action.label}
onSelect={() => {
setMenuOpen(false)
action.run()
}}
>
{t(action.label)}
</MenuItemButton>
))}
</Menu>
</div>
{expanded && <Body {...props} />}
</section>
)
}
@@ -0,0 +1,51 @@
/* A changed file's name, then its directory in smaller subdued type. */
.label {
display: flex;
min-width: 0;
}
/* The name keeps its natural width; the directory takes only the space left, so it shortens first. */
.parts {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.name {
flex: 0 1 auto;
color: var(--dsw-alias-label-primary);
}
.directory {
flex: 1 1 0;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
}
.shortened {
display: flex;
min-width: 0;
overflow: hidden;
}
.head {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tail {
flex: none;
}
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@@ -0,0 +1,66 @@
/**
* The label of a changed file in the files menu, the local changes list, and a
* file's header: its name, then its directory in smaller subdued type, with the
* complete path in a tooltip.
*/
import type { ReactNode } from 'react'
import clsx from 'clsx'
import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { pathPartsOf } from '@deepseek-ai/dsh-util-workspace-path'
import css from './FileLabel.module.css'
/** Characters at the end of a name or directory that stay whole while its start shortens. */
const KEPT_END = 12
/** Shortest start worth shortening; a text this much longer than its kept end is split. */
const MIN_START = 4
/** Hover delay before the complete path shows, so moving across a list does not flash it. */
const TOOLTIP_DELAY_MS = 400
/** Split off the end that stays whole, by characters so a surrogate pair is never cut. */
function partsOf(text: string): { readonly start: string; readonly end: string } {
const characters = Array.from(text)
if (characters.length <= KEPT_END + MIN_START) return { start: text, end: '' }
return { start: characters.slice(0, -KEPT_END).join(''), end: characters.slice(-KEPT_END).join('') }
}
/** One text whose start ends in an ellipsis when space runs out, while its end stays whole. */
function Shortened({ text, className }: { readonly text: string; readonly className: string | undefined }): ReactNode {
const { start, end } = partsOf(text)
return (
<span className={clsx(css.shortened, className)}>
<span className={css.head}>{start}</span>
{end !== '' && <span className={css.tail}>{end}</span>}
</span>
)
}
/**
* Render a changed file's name and directory. The directory gives up its space
* before the name does; assistive technology reads the complete path.
* @param props.path - the file's path in the repository.
* @param props.tooltip - the hover text, such as both paths of a renamed file; defaults to the path.
* @param props.className - class of the outer element, which callers size within their row.
* @returns the label.
*/
export function FileLabel({ path, tooltip, className }: {
readonly path: string
readonly tooltip?: string | undefined
readonly className?: string | undefined
}): ReactNode {
const { directory, name } = pathPartsOf(path)
const folder = directory.replace(/[/\\]$/, '')
return (
<Tooltip label={tooltip ?? path} side="bottom" portal delayMs={TOOLTIP_DELAY_MS} maxWidth={360}>
<span className={clsx(css.label, className)} data-forge-file-label="">
<span className={css.parts} aria-hidden="true">
<Shortened text={name} className={css.name} />
{folder !== '' && <Shortened text={folder} className={css.directory} />}
</span>
<span className={css.visuallyHidden}>{path}</span>
</span>
</Tooltip>
)
}
@@ -0,0 +1,551 @@
/* 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;
}
/* The worktree the Session works in is gone. */
.checkout[data-missing],
.checkout[data-missing] .checkoutBranch {
color: var(--dsw-alias-state-warn-primary);
}
.checkoutName {
display: inline-flex;
align-items: center;
gap: 8px;
min-width: 0;
border-radius: var(--dsw-radius-sm);
}
.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;
}
/* Smaller than the card actions, with negative block margins, so the row keeps the height of its text;
the descendant selector outranks `.iconButton`, which this button also carries. */
.checkout .checkoutCopy {
width: 24px;
height: 24px;
margin: -3px -4px -3px 0;
}
.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:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
@media (hover: hover) {
.iconButton:hover {
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);
}
/* Glyph, title link with the number line below it, then the expanded card's
open, copy, and remove buttons and the expansion toggle. */
.changeHead {
display: flex;
align-items: flex-start;
gap: 2px;
min-width: 0;
padding: 12px 8px 10px 14px;
}
.changeHead > .glyph {
margin: 3px 10px 0 0;
}
/* The buttons center on the title's first line. */
.changeHead > .iconButton {
margin-top: -3px;
}
.headText {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
padding-top: 1px;
}
.changeTitle {
display: -webkit-box;
overflow: hidden;
color: var(--dsw-alias-label-primary);
font-weight: 500;
line-height: 20px;
text-decoration: none;
overflow-wrap: anywhere;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.change:not([data-expanded]) .changeTitle {
-webkit-line-clamp: 1;
}
@media (hover: hover) {
.changeTitle:hover {
color: var(--dsw-alias-label-primary);
text-decoration: underline;
text-underline-offset: 2px;
}
}
.changeTitle:focus-visible,
.rowButton:focus-visible,
.checkoutName:focus-visible {
outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary));
outline-offset: 1px;
}
/* Number line below the title: the status while collapsed, the branches while expanded. */
.meta {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 1px 0;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
font-variant-numeric: tabular-nums;
line-height: 18px;
}
/* Lifecycle glyph; drawn in the current color unless the state tints it. */
.glyph {
display: inline-flex;
flex: none;
}
.tinted {
color: var(--dsw-alias-label-tertiary);
}
.tinted[data-tone='open'] {
color: var(--dsw-alias-state-success-primary);
}
/* Orange, because the closed glyph already takes red. */
.tinted[data-tone='blocked'] {
color: var(--dsw-alias-state-warn-primary);
}
.tinted[data-tone='merged'] {
color: var(--dsw-alias-state-done-primary);
}
.tinted[data-tone='closed'] {
color: var(--dsw-alias-state-error-primary);
}
.chevron {
flex: none;
color: var(--dsw-alias-label-tertiary);
transition: transform 120ms ease;
}
.chevronOpen {
transform: rotate(180deg);
}
.ellipsis {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.diff {
display: inline-flex;
gap: 8px;
font-variant-numeric: tabular-nums;
}
.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-done-primary);
}
/* Read failure or unsupported host below the head, with its one action. */
.notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 0 10px 10px 33px;
color: var(--dsw-alias-label-secondary);
font-size: 12px;
line-height: 18px;
}
/* Expanded facts, one row each: an icon under the glyph, the label, the value,
and a chevron on a row that opens something. */
.rows {
display: flex;
flex-direction: column;
gap: 2px;
padding: 4px 6px 6px;
border-top: 0.5px solid var(--dsw-alias-border-l2);
}
.row {
display: grid;
grid-template-columns: 16px 32px minmax(0, 1fr) 12px;
align-items: center;
column-gap: 10px;
box-sizing: border-box;
width: 100%;
min-height: 34px;
margin: 0;
padding: 0 10px 0 8px;
border: 0;
border-radius: var(--dsw-radius-sm);
background: transparent;
color: var(--dsw-alias-label-primary);
font: inherit;
text-align: left;
}
.rowWide {
grid-column: 2 / 4;
}
.rowButton {
cursor: pointer;
}
.rowButton[aria-expanded='true'] {
background: var(--dsw-alias-interactive-bg-hover);
}
@media (hover: hover) {
.rowButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
}
.rowIcon {
display: inline-flex;
color: var(--dsw-alias-label-tertiary);
}
.rowLabel {
color: var(--dsw-alias-label-secondary);
}
.rowChevron {
color: var(--dsw-alias-label-tertiary);
}
.rowValue {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.rowValue[data-tone='error'] {
color: var(--dsw-alias-state-error-primary);
}
.rowValue[data-tone='success'] {
color: var(--dsw-alias-state-success-primary);
}
.rowValue[data-tone='merged'] {
color: var(--dsw-alias-state-done-primary);
}
.rowValue[data-tone='closed'] {
color: var(--dsw-alias-label-secondary);
}
/* The checks menu anchors to the whole row. */
.checksAnchor {
display: block;
}
.checksList {
min-width: 280px;
}
.checkItem {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-width: 0;
}
.checkTime {
flex: none;
margin-left: auto;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
}
.checkGlyph {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-tertiary);
}
.pass {
color: var(--dsw-alias-state-success-primary);
}
.fail {
color: var(--dsw-alias-state-error-primary);
}
.running {
color: var(--dsw-alias-state-warn-primary);
}
/* The action of an open change request below its rows. */
.footer {
display: flex;
align-items: center;
gap: 8px;
padding: 2px 14px 12px;
}
.primaryAction {
height: 32px;
padding: 0 14px;
font-size: 13px;
line-height: 20px;
}
/* A merge method row: its name with the note on what it does below. */
.method {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.methodNote {
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
}
.titleIcon {
display: inline-flex;
flex: none;
color: var(--dsw-alias-label-secondary);
}
.titleText {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -0,0 +1,251 @@
/** Body of the forge sidebar tab: where the Session works, its local changes, and the change requests it created or pushed. */
import { useEffect, useState, type ReactNode } from 'react'
import {
Button, IconBranchOutlineRegular, IconCopyOutlineRegular, IconFolderCloseRegular, IconWarningTriangleOutlineRegular, StateDot, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { HostObservable, InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client'
import type { MergeRequest } from '@deepseek-ai/dsh-experimental-forge/types'
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 { changesAddress, type ChangesSource } from './changes-address.ts'
import { ChangeRequestCard, type ChangeRequestActions } from './ChangeRequestCard.tsx'
import { checkoutLabel } from './checkout.ts'
import { LocalChangesCard } from './LocalChangesCard.tsx'
import type { NS } from './locales.ts'
import { RefreshGlyph } from './RefreshGlyph.tsx'
import type { PanelSnapshot } from './source.ts'
import { defaultExpanded, openChangeRequestOf } from './status.ts'
import css from './ForgeTab.module.css'
/** Business face of the tab body. */
export interface ForgeTabInjected {
/** Panel snapshot of the Session and the Host facts; the renderer binds them as `usePanel` and `useHost`. */
readonly hooks: {
readonly panel: HostObservable<PanelSnapshot>
readonly host: HostObservable<RemoteHostFacts>
readonly locale: HostObservable<string>
}
/** Open the stream again after it ended. */
readonly reconnect: () => void
/**
* Reinspect the checkout, probe sign-in, and poll the change requests;
* settles once the Host has inspected and probed, after reporting a failure.
*/
readonly refresh: () => Promise<void>
/** Remove a change request from the Session, offering to undo it. */
readonly remove: (entry: ForgePanelChangeRequest) => void
/** Mark a draft ready for review; settles once the outcome is reported. */
readonly markReady: (entry: ForgePanelChangeRequest) => Promise<void>
/** Merge a change request as `request` describes; settles once the outcome is reported. */
readonly merge: (entry: ForgePanelChangeRequest, request: MergeRequest) => Promise<void>
/** Check the removed worktree of the Session out again, reporting the outcome. */
readonly restore: () => 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
/** Copy a path to the clipboard and report the outcome. */
readonly copyPath: (path: string) => void
/** Record whether the tab is visible, for the header entry's pressed state. */
readonly reportVisible: (visible: boolean) => void
/** Ask the Session's Agent to create a change request for its changes, reporting the outcome. */
readonly createChangeRequest: () => void
}
/** Full props of the tab body. */
export type ForgeTabProps =
Pick<PropsRuntime<'sidebar.right.pane.tab'>, 'useTabInfo' | 'sessionId'>
& InjectFace<ForgeTabInjected>
& PropsLocale<typeof NS>
type Translate = ForgeTabProps['t']
function Checkout({ state, home, restore, copyPath, t }: {
readonly state: ForgePanelState
readonly home: string | undefined
readonly restore: () => void
readonly copyPath: (path: string) => void
readonly t: Translate
}): ReactNode {
const checkout = state.checkout
const label = checkoutLabel(state, home, t)
if (label === undefined) {
if (checkout.state === 'none') return <div className={css.checkout}>{t('checkout.none')}</div>
if (checkout.state !== 'failed') return null
return (
<Tooltip label={checkout.message} side="bottom">
<div className={css.checkout} tabIndex={0}>{t('checkout.failed')}</div>
</Tooltip>
)
}
const copyLabel = t(label.worktree ? 'checkout.copyWorktreePath' : 'checkout.copyProjectPath')
return (
<div className={css.checkout} data-forge-checkout="" data-missing={label.missing ? '' : undefined}>
<Tooltip label={label.tooltip} side="bottom">
<span className={css.checkoutName} tabIndex={0}>
{label.missing
? <IconWarningTriangleOutlineRegular size={14} className={css.checkoutIcon} />
: label.worktree
? <IconBranchOutlineRegular size={14} className={css.checkoutIcon} />
: <IconFolderCloseRegular size={14} className={css.checkoutIcon} />}
{label.detached && <span>{t('checkout.detached')}</span>}
{label.detached && label.name !== null && <span aria-hidden="true">·</span>}
{label.name !== null && <span className={css.checkoutBranch}>{label.name}</span>}
</span>
</Tooltip>
<span className={css.checkoutNote}>{label.kind}</span>
<Tooltip label={copyLabel} side="bottom">
<button type="button" className={`${css.iconButton} ${css.checkoutCopy}`} aria-label={copyLabel} onClick={() => { copyPath(label.path) }}>
<IconCopyOutlineRegular size={14} />
</button>
</Tooltip>
{label.missing && (
<Button
size="sm"
variant="outline"
disabled={state.worktree.restoration !== null}
icon={state.worktree.restoration === null ? undefined : <StateDot state="ongoing" size={14} />}
onClick={restore}
>
{t('checkout.restore')}
</Button>
)}
</div>
)
}
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. The local changes card shows while the
* `branch` scope changes files and no open change request has the checked-out
* branch as its head. The only open change request starts expanded; once the
* user expands or collapses a card, the panel keeps that choice across later updates.
* @param props - tab information, panel snapshot and Host facts hooks, actions, and copy.
* @returns the tab body.
*/
export function ForgeTab({
useTabInfo, sessionId, usePanel, useHost, useLocale, reconnect, refresh, remove, markReady, merge, restore, openLink, copyText, copyPath,
reportVisible, createChangeRequest, t,
}: ForgeTabProps): ReactNode {
const snapshot = usePanel(current => current)
const home = useHost(facts => facts.home)
const locale = useLocale(value => value)
const { tab } = useTabInfo()
const visible = tab.visible
const [chosen, setChosen] = useState<ReadonlySet<string> | null>(null)
const [refreshing, setRefreshing] = useState(false)
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 viewChanges = (source: ChangesSource, path?: string): void => {
tab.actions.openResource(changesAddress({ sessionId, source }), path === undefined ? undefined : { params: { path } })
}
const startRefresh = (): void => {
if (refreshing) return
setRefreshing(true)
void refresh().finally(() => { setRefreshing(false) })
}
const actions: ChangeRequestActions = {
openLink, copyLink: copyText, retry: startRefresh, remove, markReady, merge,
viewChanges: (entry) => { viewChanges({ kind: 'change', url: entry.url }) },
}
const local = state.local !== null && state.local.files.length > 0 && openChangeRequestOf(state) === undefined ? state.local : null
const expanded = chosen ?? defaultExpanded(state.changeRequests)
const toggle = (url: string): void => {
const next = new Set(expanded)
if (next.has(url)) next.delete(url)
else next.add(url)
setChosen(next)
}
const now = Date.now()
const count = state.changeRequests.length
return (
<div className={css.root} data-forge-panel="">
{disconnected}
<Checkout state={state} home={home} restore={restore} copyPath={copyPath} t={t} />
{local !== null && (
<LocalChangesCard
local={local}
creatable={state.repository !== null}
view={(path) => { viewChanges({ kind: 'local' }, path) }}
create={createChangeRequest}
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')} aria-disabled={refreshing} onClick={startRefresh}>
<RefreshGlyph busy={refreshing} 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.has(entry.url)}
onToggle={() => { toggle(entry.url) }}
now={now}
actions={actions}
locale={locale}
t={t}
/>
))}
</ul>
)}
</section>
</div>
)
}
@@ -0,0 +1,21 @@
/** Chip title of the forge tab: the change request glyph and the localized tab name. */
import type { ReactNode } from 'react'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { ChangeRequestGlyph } from './glyphs.tsx'
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 (
<>
<span className={css.titleIcon}><ChangeRequestGlyph tone="open" tinted={false} size={16} /></span>
<span className={css.titleText}>{t('tab.title')}</span>
</>
)
}
@@ -0,0 +1,29 @@
/* Icon-only header entry: the shared compact Button owns the radius, height,
and interaction fills, as for the header's other actions; the status dot
sits at the glyph's top-right corner. */
.trigger {
position: relative;
flex: none;
width: 28px;
padding: 0;
color: var(--dsw-alias-label-secondary);
}
/* The forge tab is visible; the pressed fill outranks the Button's hover fill. */
.trigger[aria-pressed='true'] {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-active);
}
@media (hover: hover) {
.trigger[aria-pressed='true']:hover:not(:disabled) {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-active);
}
}
.dot {
position: absolute;
top: 6px;
right: 6px;
}
@@ -0,0 +1,68 @@
/** Session header entry that shows or hides the forge tab and marks change requests that need attention. */
import type { ReactNode } from 'react'
import { Button, 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 { ChangeRequestGlyph } from './glyphs.tsx'
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
size="sm"
className={css.trigger}
aria-label={label}
aria-pressed={pressed}
data-forge-header=""
onClick={() => { toggle(pressed) }}
>
<ChangeRequestGlyph tone="open" tinted={false} size={16} />
{attention && <StateDot state="error" size={6} className={css.dot} />}
</Button>
</Tooltip>
)
}
@@ -0,0 +1,83 @@
/**
* The panel card of the checkout's changes before a change request carries
* them: the files the `branch` scope changes, their size, which opens the
* changes view, and the action that asks the Agent for a change request.
*/
import { useState, type ReactNode } from 'react'
import { Button, IconChevronDownOutlineRegular, IconListPenOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgeLocalChanges } from '../types.ts'
import { Counts } from './DiffFile.tsx'
import { FileLabel } from './FileLabel.tsx'
import type { NS } from './locales.ts'
import css from './ChangesTab.module.css'
/** Props of the local changes card. */
export interface LocalChangesCardProps {
/** Files the `branch` scope changes and the ref it compares with. */
readonly local: ForgeLocalChanges
/** Whether the card offers to create a change request. */
readonly creatable: boolean
/** Open the changes view, scrolled to `path` when given. */
readonly view: (path?: string) => void
/** Ask the Agent to create a change request for the changes. */
readonly create: () => void
/** Forge panel copy. */
readonly t: PropsLocale<typeof NS>['t']
}
/**
* Render the local changes card; the file list starts collapsed.
* @param props - the changes, the view and create actions, and copy.
* @returns the card.
*/
export function LocalChangesCard({ local, creatable, view, create, t }: LocalChangesCardProps): ReactNode {
const [open, setOpen] = useState(false)
let additions = 0
let deletions = 0
for (const file of local.files) {
additions += file.additions
deletions += file.deletions
}
const count = local.files.length
return (
<section className={css.local} data-forge-local="">
<div className={css.localHead}>
<button type="button" className={css.localToggle} aria-expanded={open} onClick={() => { setOpen(value => !value) }}>
{t('changes.local.title')}
<IconChevronDownOutlineRegular size={12} />
</button>
<span className={css.localRefs}>{t('changes.local.refs', { head: 'HEAD', base: local.base })}</span>
</div>
<div className={css.localRow}>
<button
type="button"
className={css.localView}
aria-label={t('changes.local.viewAria', { count, added: additions, deleted: deletions })}
onClick={() => { view() }}
>
<IconListPenOutlineRegular size={15} />
<Counts file={{ additions, deletions, binary: false }} t={t} />
<span aria-hidden="true">·</span>
<span>{t('changes.local.files', { count })}</span>
</button>
{creatable && (
<Button size="sm" variant="outline" className={css.localCreate} onClick={create}>{t('changes.local.create')}</Button>
)}
</div>
{open && (
<ul className={css.localFiles}>
{local.files.map(file => (
<li key={file.path}>
<button type="button" className={css.localFile} onClick={() => { view(file.path) }}>
<FileLabel path={file.path} className={css.menuPath} />
<Counts file={file} t={t} />
</button>
</li>
))}
</ul>
)}
</section>
)
}
@@ -0,0 +1,22 @@
.glyph {
display: inline-flex;
}
.glyph[data-turning] {
animation: forge-refresh-turn 1s linear infinite;
}
@keyframes forge-refresh-turn {
to { transform: rotate(360deg); }
}
/* Reduced motion dims the glyph instead; the dim still fires the iteration events that end a turn. */
@media (prefers-reduced-motion: reduce) {
.glyph[data-turning] {
animation-name: forge-refresh-dim;
}
}
@keyframes forge-refresh-dim {
50% { opacity: 0.4; }
}
@@ -0,0 +1,35 @@
/** Refresh glyph that turns while a refresh runs. */
import { useState, type ReactNode } from 'react'
import { IconRefreshOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import css from './RefreshGlyph.module.css'
/** Props of the refresh glyph. */
export interface RefreshGlyphProps {
/** Whether the refresh runs. */
readonly busy: boolean
/** Glyph size in pixels. */
readonly size: number
}
/**
* Render the refresh glyph. It turns while `busy` and, once `busy` ends,
* completes the turn under way, so a quick refresh still shows a whole turn
* and the glyph stops upright. Under reduced motion it dims and returns instead of turning.
* @param props - whether the refresh runs, and the glyph size.
* @returns the glyph.
*/
export function RefreshGlyph({ busy, size }: RefreshGlyphProps): ReactNode {
// Set when a turn starts and cleared at the first turn boundary after `busy` ends.
const [turning, setTurning] = useState(false)
return (
<span
className={css.glyph}
data-turning={busy || turning ? '' : undefined}
onAnimationStart={() => { setTurning(true) }}
onAnimationIteration={() => { if (!busy) setTurning(false) }}
>
<IconRefreshOutlineRegular size={size} />
</span>
)
}
@@ -0,0 +1,226 @@
/* 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:focus-visible {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-secondary);
}
@media (hover: hover) {
.disclosure:hover {
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,280 @@
/**
* Card above the composer while a Session's worktree is created, while a message
* waits after a failed or stopped attempt, and when its worktree is gone or
* being restored.
*/
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, ForgeWorktreeRestoration } 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 running attempt a message waits for; the message keeps waiting. */
readonly stop: () => void
/** Make another attempt at the worktree a message waits for. */
readonly retry: () => void
/** Withdraw the worktree request, so the waiting message goes on 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'
type Phase = 'prepare' | 'checkout'
type StepList = readonly { readonly phase: Phase; readonly label: ForgePanelKey }[]
const CREATION_STEPS: StepList = [
{ phase: 'prepare', label: 'creation.prepare' },
{ phase: 'checkout', label: 'creation.checkout' },
]
const RESTORATION_STEPS: StepList = [
{ phase: 'prepare', label: 'restoration.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 Steps({ steps, phase, failed, t }: {
readonly steps: StepList
readonly phase: Phase
readonly failed: boolean
readonly t: Translate
}): ReactNode {
return (
<ul className={css.steps}>
{steps.map(step => (
<li key={step.phase} className={css.step} data-state={stepState(phase, failed, step.phase)}>
<StepIcon state={stepState(phase, failed, step.phase)} />
<span>{t(step.label)}</span>
</li>
))}
</ul>
)
}
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 attempt, or the failed or stopped one a message waits after. */
interface CreationView {
readonly state: 'running' | 'failed' | 'stopped'
readonly phase: 'prepare' | 'checkout'
readonly branch: string | null
readonly from: string | null
/** Failure text, or null unless the attempt failed. */
readonly message: string | null
/** Whether a message waits for the running attempt, which can then be stopped. */
readonly held: boolean
}
/** The running attempt, else the failed or stopped one a message waits after, or undefined without either. */
function creationOf(worktree: ForgePanelWorktree): CreationView | undefined {
const { creation, failure, waiting } = worktree
if (creation !== null) return { ...creation, state: 'running', message: null }
if (waiting !== 'creation') return undefined
if (failure !== null) return { ...failure, state: 'failed', held: false }
return { state: 'stopped', phase: 'prepare', branch: null, from: worktree.request?.from ?? null, message: null, 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>
)
}
const CREATION_TITLE: Record<CreationView['state'], ForgePanelKey> = {
running: 'creation.title',
failed: 'creation.failed',
stopped: 'creation.stopped',
}
function CreationCard({ creation, stop, retry, withdraw, t }: {
readonly creation: CreationView
readonly stop: WorktreeProgressInjected['stop']
readonly retry: WorktreeProgressInjected['retry']
readonly withdraw: WorktreeProgressInjected['withdraw']
readonly t: Translate
}): ReactNode {
const [open, setOpen] = useState(false)
const running = creation.state === 'running'
const failed = creation.state === 'failed'
const title = t(CREATION_TITLE[creation.state])
return (
<div className={css.dock} data-forge-creation={running ? creation.phase : creation.state}>
<section className={css.card} aria-label={title} aria-busy={running}>
<div className={css.header}>
<IconBranchOutlineRegular size={14} className={css.glyph} />
<span className={css.title}>{title}</span>
{creation.branch !== null && <span className={css.branch}>{creation.branch}</span>}
</div>
{creation.state !== 'stopped' && <Steps steps={CREATION_STEPS} phase={creation.phase} failed={failed} t={t} />}
{(open || failed) && <Details creation={creation} t={t} />}
{!running && <p className={css.note}>{t('creation.waiting')}</p>}
<div className={css.footer}>
{running && (
<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>
)}
{!running && (
<span className={css.actions}>
<Button size="sm" variant="primary" onClick={retry}>{t('creation.retry')}</Button>
<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, restoration, waiting, restore, leave, t }: {
readonly created: ForgeSessionWorktree
readonly missing: ForgeMissingWorktree
/** The running restoration, from whichever surface started it. */
readonly restoration: ForgeWorktreeRestoration | null
/** Whether a message waits for the worktree to be restored or left. */
readonly waiting: boolean
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))
},
)
}
// The restore this card asked for runs before the Host names its first step.
const phase = restoration?.phase ?? (restoring ? 'prepare' : null)
if (phase !== null) {
return (
<div className={css.dock} data-forge-missing="" data-forge-restoration={phase}>
<section className={css.card} aria-label={t('restoration.title')} aria-busy={true}>
<div className={css.header}>
<IconBranchOutlineRegular size={14} className={css.glyph} />
<span className={css.title}>{t('restoration.title')}</span>
<span className={css.branch}>{created.branch}</span>
</div>
<Steps steps={RESTORATION_STEPS} phase={phase} failed={false} t={t} />
</section>
</div>
)
}
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 === null ? 'missing.unknown' : missing.branchExists ? 'missing.kept' : 'missing.recreated', { branch: created.branch })}</p>
{waiting && <p className={css.note}>{t('missing.waiting')}</p>}
{error !== null && <p className={css.error}>{error}</p>}
<div className={css.footer}>
<span className={css.actions}>
<Button size="sm" variant="primary" onClick={start}>{t('missing.restore')}</Button>
<Button size="sm" variant="ghost" onClick={leave}>{t('missing.leave')}</Button>
</span>
</div>
</section>
</div>
)
}
/**
* Render the worktree card of a Session: the running attempt at its
* requested worktree, which can be stopped while a message waits for it; the
* failed or stopped attempt a message waits after, with another attempt or
* withdrawing the request so the message goes on without a worktree; or the
* removal of the worktree the Session works in, with restoring it, whose
* steps the card follows, and returning to the source checkout, after which
* a waiting message goes on.
* @param props - panel hook, actions, and copy.
* @returns the card, or nothing while no attempt runs, no message waits, and the worktree exists.
*/
export function WorktreeProgress({ usePanel, stop, retry, 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} retry={retry} withdraw={withdraw} t={t} />
if (worktree.missing === null || worktree.created === null) return null
return (
<MissingCard
created={worktree.created}
missing={worktree.missing}
restoration={worktree.restoration}
waiting={worktree.waiting === 'restoration'}
restore={restore}
leave={leave}
t={t}
/>
)
}
@@ -0,0 +1,107 @@
/* 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;
}
@media (hover: hover) {
.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,108 @@
/**
* The changes view's tab type: the right-Sidebar view of one Session's local
* changes or of one change request bound to it. Its addresses name the Session
* and the source, `dsh-resource://forge-changes/<session>/local` or
* `dsh-resource://forge-changes/<session>/change/<url>`, each part
* percent-encoded; the source names the tab.
*/
import type { SidebarRightTabDefinition } from '@deepseek-ai/dsh-client-ui-sidebar-right/client'
import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { NS } from './locales.ts'
import { numberOf } from './status.ts'
/** Kind of the changes tab. */
export const CHANGES_KIND = 'forge-changes'
/** Registry id of the changes tab type, also the key its body and title register under. */
export const CHANGES_ID = '@deepseek-ai/dsh-experimental-client-ui-forge/changes'
const PREFIX = 'dsh-resource://forge-changes/'
/** What a changes view shows. */
export type ChangesSource =
/** The changes of the Session's checkout. */
| { readonly kind: 'local' }
/** The changes of one change request bound to the Session, by its binding URL. */
| { readonly kind: 'change'; readonly url: string }
/** The Session and the source one address names. */
export interface ChangesTarget {
/** Session whose checkout or binding the view reads. */
readonly sessionId: SessionId
/** What the view shows. */
readonly source: ChangesSource
}
/** Navigation parameters of a changes tab. */
export interface ChangesParams {
/** File to scroll to, by its path in the diff. */
readonly path?: string
}
declare module '@deepseek-ai/dsh-client-ui-sidebar-right/client' {
interface SidebarRightResourceParamsMap {
/** The file a changes tab scrolls to. */
'forge-changes': ChangesParams
}
}
/**
* Mint the address of a changes view.
* @param target - Session and source.
* @returns the `dsh-resource://forge-changes/…` address.
*/
export function changesAddress(target: ChangesTarget): string {
const session = `${PREFIX}${encodeURIComponent(target.sessionId)}/`
return target.source.kind === 'local' ? `${session}local` : `${session}change/${encodeURIComponent(target.source.url)}`
}
/**
* Read the Session and source back out of a changes address.
* @param address - a resource address.
* @returns the target, or undefined for an address this package did not mint.
*/
export function parseChangesAddress(address: string): ChangesTarget | undefined {
if (!address.startsWith(PREFIX)) return undefined
const [session = '', kind, url, ...rest] = address.slice(PREFIX.length).split('/')
if (session === '' || rest.length > 0) return undefined
try {
const sessionId = decodeURIComponent(session) as SessionId
if (kind === 'local' && url === undefined) return { sessionId, source: { kind: 'local' } }
if (kind === 'change' && url !== undefined && url !== '') return { sessionId, source: { kind: 'change', url: decodeURIComponent(url) } }
return undefined
} catch (error) {
void error // A malformed percent sequence is not an address this package minted.
return undefined
}
}
/**
* Name a changes view: 变更 for the local changes, #N 的变更 for a change request with a number.
* @param source - what the view shows.
* @param t - forge panel copy.
* @returns the title in the current language.
*/
export function changesTitle(source: ChangesSource, t: TranslateNS<typeof NS>): string {
const number = source.kind === 'change' ? numberOf({ ref: null, url: source.url }) : null
return number === null ? t('diff.tab') : t('diff.tabNumbered', { number })
}
/**
* The changes type's registry definition.
* @param t - forge panel copy, read on every title call.
* @returns the definition to register.
*/
export function changesDefinition(t: TranslateNS<typeof NS>): SidebarRightTabDefinition {
return {
id: CHANGES_ID,
kind: CHANGES_KIND,
patterns: [`${PREFIX}**`],
canOpen: address => parseChangesAddress(address) !== undefined,
title: (address) => {
const target = parseChangesAddress(address)
return target === undefined ? address : changesTitle(target.source, t)
},
}
}
@@ -0,0 +1,98 @@
/**
* Choices of each changes tab: the local scope, the side-by-side view, line
* wrapping, and the navigation it last applied. One bucket per tab, so two
* views in one Session keep their own choices; the bucket ends with the tab
* record's signal.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-store'
import type { ForgeLocalScope } from '../types.ts'
/** One changes tab's choices. */
export interface ChangesTabState {
/** Local changes shown; a change request view ignores it. */
scope: ForgeLocalScope['kind']
/** Whether deletions and additions show in two columns. */
split: boolean
/** Whether long lines wrap instead of being cut at the column edge. */
wrap: boolean
/** The navigation revision last applied, whose file the view scrolled to. */
navigated: number
}
/** Every changes tab's choices, keyed by tab id. */
export interface ChangesState {
byTab: Record<string, ChangesTabState>
}
function bucket(state: ChangesState, tabId: string): ChangesTabState {
const tab = state.byTab[tabId]
if (tab === undefined) throw new Error(`forge-panel: no changes state for tab "${tabId}"`)
return tab
}
/** The changes store's write set; every action names the tab it writes. */
type ChangesActions = {
navigated: (draft: ChangesState, tabId: string, revision: number) => void
scoped: (draft: ChangesState, tabId: string, scope: ForgeLocalScope['kind']) => void
toggledSplit: (draft: ChangesState, tabId: string) => void
toggledWrap: (draft: ChangesState, tabId: string) => void
forget: (draft: ChangesState, tabId: string) => void
}
/**
* Declare the changes tab's store; the registration declares it as an
* exclusive store, so the framework mints one instance per Session.
* @returns the store handle to declare on the registration.
*/
export function createChangesStore(): EngineStoreHandle<ChangesState, ChangesActions> {
return defineStore({
init: (): ChangesState => ({ byTab: {} }),
actions: {
/**
* Apply a navigation; the first one seeds the `branch` scope, two columns, and wrapped lines.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param revision - the navigation revision being applied.
*/
navigated: (d, tabId: string, revision: number) => {
const tab = d.byTab[tabId]
if (tab === undefined) d.byTab[tabId] = { scope: 'branch', split: true, wrap: true, navigated: revision }
else tab.navigated = revision
},
/**
* Show another scope of local changes.
* @param d - draft state.
* @param tabId - the tab being drawn.
* @param scope - the scope to show.
*/
scoped: (d, tabId: string, scope: ForgeLocalScope['kind']) => { bucket(d, tabId).scope = scope },
/**
* Switch between one column and two.
* @param d - draft state.
* @param tabId - the tab being drawn.
*/
toggledSplit: (d, tabId: string) => {
const tab = bucket(d, tabId)
tab.split = !tab.split
},
/**
* Switch line wrapping.
* @param d - draft state.
* @param tabId - the tab being drawn.
*/
toggledWrap: (d, tabId: string) => {
const tab = bucket(d, tabId)
tab.wrap = !tab.wrap
},
/**
* Drop a tab's bucket once its record is gone.
* @param d - draft state.
* @param tabId - the tab that ended.
*/
forget: (d, tabId: string) => {
d.byTab = Object.fromEntries(Object.entries(d.byTab).filter(([id]) => id !== tabId))
},
},
})
}
@@ -0,0 +1,75 @@
/** What the panel's checkout row says about the checkout the Session works in. */
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import { abbreviateHomePath } from '@deepseek-ai/dsh-util-workspace-path'
import type { ForgePanelState } from '../types.ts'
import type { NS } from './locales.ts'
type Translate = PropsLocale<typeof NS>['t']
/** Facts the checkout row shows. */
export interface CheckoutLabel {
/** Branch name, the abbreviated commit while HEAD is detached, or null when HEAD has no commit. */
readonly name: string | null
/** Whether HEAD is detached. */
readonly detached: boolean
/** Kind of directory: a worktree with its base, another worktree, the project directory, or a removed worktree. */
readonly kind: string
/** The kind with the directory path, home abbreviated to `~`. */
readonly tooltip: string
/** Whether the worktree the Session works in is gone. */
readonly missing: boolean
/** Full path of the directory, home not abbreviated; a removed worktree keeps the path it was created at. */
readonly path: string
/** Whether the directory is a worktree rather than the project directory. */
readonly worktree: boolean
}
/**
* Describe the checkout of a Session. A worktree this panel created names its
* base; any other linked worktree is a worktree, and the main checkout is the
* project directory. A removed worktree keeps its branch name.
* @param state - panel state.
* @param home - Host account home, or undefined to show full paths.
* @param t - forge panel copy.
* @returns the label, or undefined before a successful inspection.
*/
export function checkoutLabel(state: ForgePanelState, home: string | undefined, t: Translate): CheckoutLabel | undefined {
const created = state.worktree.created
if (state.worktree.missing !== null && created !== null) {
const path = abbreviateHomePath(created.path, home)
return {
name: created.branch,
detached: false,
kind: t('missing.title'),
tooltip: t('branch.missing', { path }),
missing: true,
path: created.path,
worktree: true,
}
}
if (state.checkout.state !== 'ready') return undefined
const { root, branch, linked, commit } = state.checkout.checkout
const path = abbreviateHomePath(root, home)
if (created !== null && created.path === root) {
const base = created.from ?? created.baseCommit.slice(0, 7)
return {
name: branch ?? commit,
detached: branch === null,
kind: t('checkout.worktreeFrom', { base }),
tooltip: t('branch.worktree', { base, path }),
missing: false,
path: root,
worktree: true,
}
}
return {
name: branch ?? commit,
detached: branch === null,
kind: t(linked ? 'checkout.worktree' : 'checkout.project'),
tooltip: t(linked ? 'branch.linked' : 'branch.project', { path }),
missing: false,
path: root,
worktree: linked,
}
}
@@ -0,0 +1,199 @@
/**
* Drawn rows of one file diff: numbered patch lines, their side-by-side
* pairing, and the unchanged lines between hunks, which stay collapsed until
* the file's new side supplies them.
*/
import type { ForgeDiffFile, ForgeDiffHunk } from '../types.ts'
/** One drawn line with its number on each side that has it. */
export type DiffRow =
| { readonly kind: 'add'; readonly old: null; readonly new: number; readonly text: string }
| { readonly kind: 'del'; readonly old: number; readonly new: null; readonly text: string }
| { readonly kind: 'context'; readonly old: number; readonly new: number; readonly text: string }
/** One side of a side-by-side row. */
export interface SplitCell {
readonly kind: DiffRow['kind']
readonly no: number
readonly text: string
}
/** One side-by-side row; a side without a line is null. */
export interface SplitRow {
readonly left: SplitCell | null
readonly right: SplitCell | null
}
/** A run of drawn rows, or unchanged lines drawn as one row that expands them. */
export type DiffSegment =
| { readonly kind: 'rows'; readonly rows: readonly DiffRow[] }
/**
* `index` is the hunk the lines precede, or the hunk count for the lines after the last hunk;
* `count` is null while the length of the file is unknown.
*/
| { readonly kind: 'gap'; readonly index: number; readonly count: number | null }
/** A line as drawn: without the carriage return of a CRLF line ending. */
function drawn(text: string): string {
return text.endsWith('\r') ? text.slice(0, -1) : text
}
/**
* Number the lines of a hunk. Context lines count on both sides, deletions on
* the old side, and additions on the new side; a `\ No newline at end of file`
* marker is not a line.
* @param hunk - one hunk.
* @returns the rows in patch order.
*/
export function hunkRows(hunk: ForgeDiffHunk): DiffRow[] {
let oldNo = hunk.oldStart
let newNo = hunk.newStart
const rows: DiffRow[] = []
for (const line of hunk.lines) {
const sign = line.charAt(0)
const text = drawn(line.slice(1))
if (sign === '+') rows.push({ kind: 'add', old: null, new: newNo++, text })
else if (sign === '-') rows.push({ kind: 'del', old: oldNo++, new: null, text })
else if (sign !== '\\') rows.push({ kind: 'context', old: oldNo++, new: newNo++, text })
}
return rows
}
/**
* Pair rows for the side-by-side view: each run of deletions sits beside the
* run of additions that follows it, row by row, and context rows fill both sides.
* @param rows - rows in patch order.
* @returns the paired rows.
*/
export function splitRows(rows: readonly DiffRow[]): SplitRow[] {
const paired: SplitRow[] = []
let deleted: SplitCell[] = []
let added: SplitCell[] = []
const flush = (): void => {
for (let at = 0; at < Math.max(deleted.length, added.length); at++) {
paired.push({ left: deleted[at] ?? null, right: added[at] ?? null })
}
deleted = []
added = []
}
for (const row of rows) {
switch (row.kind) {
case 'del':
if (added.length > 0) flush()
deleted.push({ kind: 'del', no: row.old, text: row.text })
break
case 'add':
added.push({ kind: 'add', no: row.new, text: row.text })
break
case 'context':
flush()
paired.push({ left: { kind: 'context', no: row.old, text: row.text }, right: { kind: 'context', no: row.new, text: row.text } })
break
}
}
flush()
return paired
}
/**
* Split file text into lines as a patch numbers them; a final line ending ends the last line.
* @param text - the file text.
* @returns the lines with any carriage returns kept.
*/
export function textLines(text: string): string[] {
const lines = text.split('\n')
if (lines.at(-1) === '') lines.pop()
return lines
}
/**
* Whether file text is the new side the hunks describe: every context and
* added line sits at its number. A file changed after its diff was read fails.
* @param lines - the file's lines.
* @param hunks - hunks of the file's diff.
* @returns whether the lines match.
*/
export function matchesHunks(lines: readonly string[], hunks: readonly ForgeDiffHunk[]): boolean {
return hunks.every((hunk) => {
let at = hunk.newStart - 1
for (const line of hunk.lines) {
const sign = line.charAt(0)
if (sign === '-' || sign === '\\') continue
if (lines[at] !== line.slice(1)) return false
at++
}
return true
})
}
/** Whether the hunk ends the new side: a missing-newline marker follows one of its new-side lines. */
function endsNewSide(hunk: ForgeDiffHunk): boolean {
let previous = '-'
for (const line of hunk.lines) {
if (line.startsWith('\\') && !previous.startsWith('-')) return true
previous = line
}
return false
}
/**
* Lay out one file as runs of rows separated by collapsed unchanged lines.
* Unchanged lines before a hunk, between hunks, and after the last hunk
* collapse into one gap each; an open gap draws its lines from `lines` once
* they are known. The lines after the last hunk are unknown until `lines`
* arrives, unless the file is new, deleted, or its last hunk reaches its end.
* @param file - one file of a diff.
* @param lines - the file's new side, which {@link matchesHunks} accepted, or undefined while it has not been read.
* @param open - indexes of the gaps the user expanded.
* @returns the segments in file order.
*/
export function segmentsOf(file: ForgeDiffFile, lines: readonly string[] | undefined, open: ReadonlySet<number>): DiffSegment[] {
const segments: DiffSegment[] = []
let rows: DiffRow[] = []
const flush = (): void => {
if (rows.length > 0) segments.push({ kind: 'rows', rows })
rows = []
}
/** Unchanged new-side lines `from` to `to`, null while unknown, whose old numbers are `offset` higher. */
const gap = (index: number, from: number, to: number | null, offset: number): void => {
if (lines !== undefined && to !== null && open.has(index)) {
for (const [at, text] of lines.slice(from - 1, to).entries()) {
rows.push({ kind: 'context', old: from + at + offset, new: from + at, text: drawn(text) })
}
return
}
flush()
segments.push({ kind: 'gap', index, count: to === null ? null : to - from + 1 })
}
let newEnd = 0
let oldEnd = 0
for (const [index, hunk] of file.hunks.entries()) {
if (hunk.newStart - 1 > newEnd) gap(index, newEnd + 1, hunk.newStart - 1, hunk.oldStart - hunk.newStart)
rows.push(...hunkRows(hunk))
newEnd = hunk.newStart + hunk.newLines - 1
oldEnd = hunk.oldStart + hunk.oldLines - 1
}
const last = file.hunks.at(-1)
if (last !== undefined && file.status !== 'added' && file.status !== 'deleted' && !endsNewSide(last)) {
if (lines === undefined) gap(file.hunks.length, newEnd + 1, null, oldEnd - newEnd)
else if (lines.length > newEnd) gap(file.hunks.length, newEnd + 1, lines.length, oldEnd - newEnd)
}
flush()
return segments
}
/**
* Count the rows a file's hunks draw.
* @param file - one file of a diff.
* @returns the number of context, added, and deleted lines in its hunks.
*/
export function drawnLines(file: ForgeDiffFile): number {
let count = 0
for (const hunk of file.hunks) {
for (const line of hunk.lines) {
if (!line.startsWith('\\')) count++
}
}
return count
}
@@ -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 })
}
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more