Merge remote-tracking branch 'origin/master' into worktree/forge-diff-review-state

# Conflicts:
#	packages/experimental/client-ui-forge/README.i18n.yaml
#	packages/experimental/client-ui-forge/README.md
#	packages/experimental/client-ui-forge/README.zh.md
This commit is contained in:
Yichen Jiang committed 2026-10-26 22:38:20 +08:00
commit f92fadd30c
55 files changed
+2229 -160

No files matched your search

@@ -9,14 +9,14 @@
en: 344bcc7e9825bbc8
zh: c1d0f4b54f5b3786
/agent-note-change-request-panel-and-worktree-sessions/decision:
en: 494b10799ca74ab7
zh: fd55bf793ee6c643
en: b18b287b701aef93
zh: 462c0f561f1a0c18
/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
en: 91b3338f958ad0e9
zh: b64f30d333280084
/agent-note-change-request-panel-and-worktree-sessions/consequences:
en: 97976f073b9f65e0
zh: 95be3f7f375b0876
@@ -10,7 +10,7 @@ English | [中文](2026-10-09-forge-panel.zh.md)
## 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.
[`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, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). 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.
@@ -18,7 +18,7 @@ Status polling follows panel subscriptions: a Session's bindings are watched onl
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.
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, and the Session list marks a Session that works in its created worktree.
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.
@@ -67,7 +67,7 @@ The changes view offers no scope for one commit or for the last turn yet; both w
**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.
**Manual URL tracking.** It duplicated the bindings the Session's own commands record. 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.
@@ -10,7 +10,7 @@ Status: implemented
## 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 服务作为插件页中默认禁用的一个条目发布。
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(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` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
@@ -18,7 +18,7 @@ Remote 通过 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 开始。勾选新会话那一行中的选项(一个 `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 所在的分支,会话列表标出在所创建 worktree 中工作的 Session。
Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;Agent 空闲时进入的每条消息都会启动一次暂留并再确认一次,目录缺失期间消息等待、不开始这一轮。这次确认必须发生在这一轮之前:组装一个步骤的上下文时,工作目录服务会把目录已不存在的 Session 移回其项目,而这发生在任何 pre-step 监听器运行之前。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。两者都在暂留内运行,之后放行等待中的消息。
@@ -67,7 +67,7 @@ Session 所在的 worktree 可能在 Session 之外被删除。每次检查都
**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。
**Session 行悬停分区与手动粘贴 URL 跟踪。** 二者都与标签页重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**手动粘贴 URL 跟踪。** 它与 Session 自己的命令记录下的绑定重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-20-forge-session-list.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-20-forge-session-list.md
/agent-note-forge-state-in-the-session-list:
en: b5876f7873857921
zh: 56fac53370de206d
/agent-note-forge-state-in-the-session-list/problem:
en: 07ba0c3bf73a2446
zh: 2413783a6697133e
/agent-note-forge-state-in-the-session-list/decision:
en: e1d03cf4e1afd5aa
zh: b2386cb7c17c58dd
/agent-note-forge-state-in-the-session-list/alternatives-considered:
en: 3b08440ee2bf7809
zh: a90e3fcb89cf3f74
/agent-note-forge-state-in-the-session-list/consequences:
en: 25a17eac43beb705
zh: c138f693ff07c91d
@@ -0,0 +1,39 @@
# Agent Note: Forge state in the Session list
Status: implemented
English | [中文](2026-10-20-forge-session-list.zh.md)
## Problem
The [forge panel](2026-10-09-forge-panel.md) named a Session's branch in a chip beside the Session title. The header's action row does not shrink, so a long branch overlapped other header entries such as the background task label. Finding which Sessions work in their own worktrees, and which of their change requests need attention, also meant opening each Session's tab. The Session list offers seats for row icons and a hover card section, `sidebar.session.row.trailing` and `sidebar.session.row.hover`, whose occupants must not load or bind Sessions. A row needs two kinds of fact: what the Session log records, which is the worktree the panel created and entered and the change requests the Session binds, and what only polling knows, which is each change request's state and checks.
## Decision
The chip beside the Session title is gone, and the tab's first row names the branch. A Session row shows, after its time, a branch glyph while the Session works in the worktree the panel created for it, and the glyph of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. An open change request's glyph keeps the row's subdued color with a dot for its checks; merged and closed ones keep their state colors. The row's hover card section names the worktree branch and what it started from, then up to three change requests with their state and checks, and how many more there are.
Log facts reach the list as projection client views, which the Session summary carries. The `forgeWorktree` projection also records the Session's directory from its header and each `working-directory/change`, and its view is the created worktree while that directory is the worktree or lies below it, else null. The `forgeChangeRequests` view lists the bindings without status, as the [binding decision](2026-10-09-session-change-request-bindings.md) intended. The projection cache keeps both for Sessions that are not loaded.
Polled states reach the list through the `forgePanel.changeRequestStates` stream. One Host run serves every stream and stops with the last one. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of loaded Sessions from `ctx.forgeSession` and those of other Sessions from their cached summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through the shared `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. Every changed state is stored in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL, so a restart shows the last known states before the next poll, and unbound stored states are dropped only when every listed Session has known bindings. A stream sends a `baseline` of the states its caller may read, then `changed` frames, filtered through Remote access.
The browser keeps one source of states for every row and hover section: the first subscriber opens the stream, the last one closes it, and a transport reset reopens it.
## Alternatives considered
**Leaving change requests to the tab.** The forge panel first rejected a Session-row hover section because, for one Session, it repeated the tab. The list compares every Session at once, which no tab does, and the hover section adds only the facts a row's glyphs summarize.
**Keeping the branch beside the Session title.** Truncating the chip would hide the branch name, and the header shows only the open Session; the list shows every Session, and the tab names the branch in full.
**Polled states in a projection.** Projections fold the Session log, and polled states are not logged. A projection would either log every poll, adding events that no model request needs, or depend on process-local state that a replay cannot reconstruct.
**Following every listed Session's panel.** A panel inspects its checkout with Git in the Session's execution world and probes sign-in; the list needs only bindings and change request states.
**Loading listed Sessions to read their bindings.** Listing would replay every Session log; the cached summary answers without loading.
**Keeping the states only in memory.** Every row would show unknown states after a restart until each change request is polled again. The stored states are derived data, so a record that fails its schema is set aside instead of failing the run.
**A branch line for a Session that works in its project directory.** Reading that branch needs Git in the Session's execution world, which the list must not reach without loading the Session.
## Consequences
The list shows every Session's worktree and change request states without loading a Session, a restart shows the last known states at once, and the header no longer overlaps. While a row is shown, every open change request that a listed Session binds is polled, so many Sessions with open change requests mean more requests to the code host; closed requests keep polling at the idle cadence, and merged ones stop automatic polling. A Session without a cached summary shows its worktree and change requests in the list only after it is opened, and one last used before this change shows its worktree only then, because the `forgeWorktree` state changed and older cached rows are discarded. Checks that finish while no row is shown produce no notification.
@@ -0,0 +1,39 @@
# Agent Note: 会话列表中的 forge 状态
Status: implemented
[English](2026-10-20-forge-session-list.md) | 中文
## Problem
[forge 面板](2026-10-09-forge-panel.zh.md)原先在 Session 标题旁的标签中写出 Session 的分支。标题栏的操作行不会收缩,因此较长的分支名会与后台任务标签等其他标题栏条目重叠。想知道哪些 Session 在自己的 worktree 中工作、它们的哪些变更请求需要处理,也必须逐个打开 Session 的标签页。会话列表为行图标与悬浮卡片提供了位置,即 `sidebar.session.row.trailing` 与 `sidebar.session.row.hover`,其中的条目不得加载或绑定 Session。一行需要两类事实:Session 日志记录的事实,即面板创建并进入的 worktree 以及 Session 绑定的变更请求;以及只有轮询才知道的事实,即每个变更请求的状态与检查。
## Decision
Session 标题旁的标签已移除,由标签页顶部一行写出分支。Session 在面板为它创建的 worktree 中工作时,会话行在时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查;已合并与已关闭的保留各自的状态颜色。会话行悬浮卡片中的部分写出 worktree 分支及其起点,再列出最多三个变更请求的状态与检查,并说明还有多少个。
日志事实经投影的客户端视图进入列表,由 Session 摘要携带。`forgeWorktree` 投影还从 Session 头部与每个 `working-directory/change` 记录 Session 的目录;该目录就是所创建的 worktree 或位于其下时,视图为该 worktree,否则为 null。`forgeChangeRequests` 视图列出绑定但不含状态,与[绑定决策](2026-10-09-session-change-request-bindings.zh.md)的设想一致。投影缓存为未加载的 Session 保留这两项。
轮询到的状态经 `forgePanel.changeRequestStates` 流进入列表。宿主用一次运行服务所有流,最后一条流结束时运行停止。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的摘要;它跟随已加载 Session 的绑定变化,并经共享的 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,因此重启后在下次轮询之前就能显示最近已知的状态;仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态。每条流先发送调用方可读状态的 `baseline`,之后发送 `changed` 帧,均经 Remote 访问控制过滤。
浏览器为所有会话行与悬浮卡片部分只保留一个状态来源:第一个订阅方打开流,最后一个关闭它,传输重置后重新打开。
## Alternatives considered
**把变更请求只留给标签页。** forge 面板最初否决了 Session 行悬浮分区,因为对单个 Session 而言它与标签页重复。列表能同时比较所有 Session,这是任何标签页都做不到的;悬浮部分也只补充会话行图标所概括的事实。
**保留 Session 标题旁的分支。** 截断标签会遮住分支名,而且标题栏只显示当前打开的 Session;列表显示每个 Session,标签页则完整写出分支。
**把轮询状态放进投影。** 投影折叠的是 Session 日志,而轮询到的状态不写入日志。放进投影要么为每次轮询记录事件,而这些事件没有任何模型请求需要;要么让投影依赖重放无法重建的进程内状态。
**为每个已列出的 Session 跟随其面板。** 面板会在 Session 的执行环境中用 Git 检查检出并检查登录状态;列表只需要绑定与变更请求状态。
**加载已列出的 Session 来读取绑定。** 列表将重放每个 Session 的日志;缓存的摘要无需加载就能回答。
**只把状态保存在内存中。** 重启后每一行都会显示未知状态,直到每个变更请求再次被轮询。已存状态是派生数据,因此不符合 schema 的记录会被放到一边,而不是让运行失败。
**为在项目目录中工作的 Session 显示分支一行。** 读取该分支需要在 Session 的执行环境中运行 Git,而列表不加载 Session 就无法到达那里。
## Consequences
列表无需加载 Session 就能显示每个 Session 的 worktree 与变更请求状态,重启后立即显示最近已知的状态,标题栏也不再重叠。有会话行显示期间,已列出 Session 绑定的每个打开中的变更请求都会被轮询,因此有大量带打开变更请求的 Session 时,对代码托管平台的请求会增多;已关闭的继续按空闲间隔轮询,已合并的停止自动轮询。没有缓存摘要的 Session 要打开之后才会在列表中显示其 worktree 与变更请求;此变更之前最后使用的 Session 也要到那时才显示其 worktree,因为 `forgeWorktree` 的状态已变化,旧的缓存行会被丢弃。没有会话行显示期间结束的检查不会有通知。
@@ -0,0 +1 @@
- text: "dsh-{{worktree}} 工作树 · 基于 release #7 Add the pull request panel 需要处理 · 检查失败"
+12
View File
@@ -392,6 +392,18 @@ it.skipIf(MODE === 'record')('starts a Session in a worktree before its first me
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')
// The Session row marks the worktree and its change request's state, and its hover card names both.
const row = page.locator(`[data-row-key="session:${sessionId}"]`)
await row.getByRole('img', { name: '在自己的工作树中', exact: true }).waitFor({ timeout: 20_000 })
await row.getByRole('img', { name: '变更请求状态:需要处理 · 检查失败', exact: true }).waitFor({ timeout: 20_000 })
await row.hover()
const rowHover = page.locator('[data-forge-session-hover]')
await rowHover.getByText('#7 Add the pull request panel', { exact: true }).waitFor({ timeout: 20_000 })
await compareOrRefreshGolden(join(EXPECTED, 'session-row-hover.expected.md'),
await captureStableAria(page, '[data-forge-session-hover]', scaffold.workspaceCwd, { replacements: [[branch, 'dsh-{{worktree}}']] }), MODE)
await page.mouse.move(0, 0)
await rowHover.waitFor({ state: 'detached' })
await header.click()
await expect.poll(() => header.getAttribute('aria-pressed')).toBe('true')
const panel = page.locator('[data-forge-panel]')
+2 -2
View File
@@ -1033,7 +1033,7 @@ export interface CotTranslationPreferences {
## `@deepseek-ai/dsh-experimental-client-ui-forge`
- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents`
- `source`: [`packages/experimental/client-ui-forge/src/types.ts:296`](../packages/experimental/client-ui-forge/src/types.ts)
- `source`: [`packages/experimental/client-ui-forge/src/types.ts:318`](../packages/experimental/client-ui-forge/src/types.ts)
```ts config-catalog
/** Host settings of the forge panel Remote. */
@@ -1116,7 +1116,7 @@ export type Config = CuaConfig
export interface Config {
/** Poll interval in milliseconds while checks run or mergeability is unknown. */
activeIntervalMs?: number
/** Poll interval in milliseconds for an open change request whose checks have settled. */
/** Poll interval in milliseconds for a closed request or an open request whose checks have settled. */
idleIntervalMs?: number
/** Largest delay in milliseconds between polls after consecutive failures. */
maxBackoffMs?: number
+2 -2
View File
@@ -1035,7 +1035,7 @@ export interface CotTranslationPreferences {
## `@deepseek-ai/dsh-experimental-client-ui-forge`
- `inject`: `forge` · `forgeSession` · `sessionProjections` · `workingDirectory` · `fs` · `subprocess` · `sandbox` · `sandboxPolicy` · `agents`
- `source`: [`packages/experimental/client-ui-forge/src/types.ts:296`](../packages/experimental/client-ui-forge/src/types.ts)
- `source`: [`packages/experimental/client-ui-forge/src/types.ts:318`](../packages/experimental/client-ui-forge/src/types.ts)
```ts config-catalog
/** Host settings of the forge panel Remote. */
@@ -1118,7 +1118,7 @@ export type Config = CuaConfig
export interface Config {
/** Poll interval in milliseconds while checks run or mergeability is unknown. */
activeIntervalMs?: number
/** Poll interval in milliseconds for an open change request whose checks have settled. */
/** Poll interval in milliseconds for a closed request or an open request whose checks have settled. */
idleIntervalMs?: number
/** Largest delay in milliseconds between polls after consecutive failures. */
maxBackoffMs?: number
+4 -4
View File
@@ -15,8 +15,8 @@
en: 00e4490e416099d2
zh: 5724d08b6ae230f7
/forges/session-bindings:
en: 4d65f16562d67181
zh: 676e411b32bc7615
en: 13dfd36f2166bba0
zh: a369434f52f04d57
/forges/panel:
en: 7a9044e9b9f69545
zh: 5d25017909e3bb28
en: 9a84a26d97cb46f0
zh: 5e1e03b7714bcbae
+41 -1
View File
@@ -248,7 +248,7 @@ interface ForgeActionFailure {
## Session bindings
`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.
`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's client view, which the Session summary carries, lists the bindings without their status. The [Session binding README](../../packages/experimental/forge-session/README.md#use-this-package) owns the recognition and discovery rules.
```ts type-equiv
/**
@@ -506,6 +506,32 @@ interface ForgeBranches {
}
```
The Session list reads through the same Remote. `changeRequestStates` streams `ForgeChangeRequestStatesFrame` values: a `baseline` with the last known `ForgeChangeRequestListState` of every change request that a listed Session the caller may read binds, then the states that change. One Host run serves every stream; it takes the bindings of loaded Sessions from `ctx.forgeSession` and those of other Sessions from their cached Session summary without loading them, keeps each bound change request subscribed, including closed requests polled at the idle cadence to detect reopening, and stores every change, so a restart shows the last known states before the next poll. The Session summary also carries the `forgeWorktree` projection's client view: the created worktree while the Session's directory is that worktree or lies below it, else null.
```ts type-equiv
/**
* The last polled state of one change request that a listed Session binds, as
* the Session list shows it.
*/
type ForgeChangeRequestListState = Pick<
ChangeRequestSnapshot, 'url' | 'title' | 'state' | 'draft' | 'checksRollup' | 'reviewDecision' | 'mergeability'
> & {
/** Change request number on its code host. */
readonly number: number
}
```
```ts type-equiv
/**
* One frame of `forgePanel.changeRequestStates`: the known state of every
* change request the reader may see first, then the states that changed or
* became visible since the previous frame.
*/
type ForgeChangeRequestStatesFrame =
| { readonly type: 'baseline'; readonly states: readonly ForgeChangeRequestListState[] }
| { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] }
```
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
@@ -785,6 +811,20 @@ Serves the forge panel. Following a Session inspects the checkout of its current
*/
@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable<ForgePanelState>
/**
* Stream the last known state of every change request that a listed
* Session the caller may read binds: the stored states first, then each
* state a poll changes or a new binding reveals. Listing reads no Session
* log: loaded Sessions report their bindings, others the cached Session
* summary, and a Session without one shows nothing until it is opened.
* Closed requests are polled at the idle cadence so reopening is observed. The
* stream ends when `signal` aborts or the Remote stops.
* @param signal - ends the stream.
* @returns frames in publication order.
* @throws when the Host lacks the Session query, projection cache, storage, or Session store service.
*/
@Remote({ mode: 'stream' }) async *changeRequestStates(signal: AbortSignal): AsyncIterable<ForgeChangeRequestStatesFrame>
/**
* List the local branches that can start a worktree of the Session's current checkout.
* @param session - Session whose current directory is listed.
+41 -1
View File
@@ -248,7 +248,7 @@ interface ForgeActionFailure {
## Session 绑定
`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)。
`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 摘要下发,列出绑定但不含其状态。识别与发现规则见 [Session 绑定 README](../../packages/experimental/forge-session/README.zh.md#use-this-package)。
```ts type-equiv
/**
@@ -506,6 +506,32 @@ interface ForgeBranches {
}
```
会话列表经同一个 Remote 读取。`changeRequestStates` 以流的形式推送 `ForgeChangeRequestStatesFrame`:先是一个 `baseline`,包含调用方可读的已列出 Session 所绑定的每个变更请求最近一次已知的 `ForgeChangeRequestListState`,之后是发生变化的状态。宿主用一次运行服务所有流:已加载 Session 的绑定取自 `ctx.forgeSession`,其他 Session 的绑定取自缓存的 Session 摘要而不加载它们;每个已绑定的变更请求保持订阅,已关闭的按空闲间隔轮询以检测重新打开,每次变化都会存储,因此重启后在下次轮询之前就能显示最近已知的状态。Session 摘要还带有 `forgeWorktree` 投影的客户端视图:Session 的目录就是所创建的 worktree 或位于其下时为该 worktree,否则为 null。
```ts type-equiv
/**
* The last polled state of one change request that a listed Session binds, as
* the Session list shows it.
*/
type ForgeChangeRequestListState = Pick<
ChangeRequestSnapshot, 'url' | 'title' | 'state' | 'draft' | 'checksRollup' | 'reviewDecision' | 'mergeability'
> & {
/** Change request number on its code host. */
readonly number: number
}
```
```ts type-equiv
/**
* One frame of `forgePanel.changeRequestStates`: the known state of every
* change request the reader may see first, then the states that changed or
* became visible since the previous frame.
*/
type ForgeChangeRequestStatesFrame =
| { readonly type: 'baseline'; readonly states: readonly ForgeChangeRequestListState[] }
| { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] }
```
变更视图经同一个 Remote 读取。`localDiff` 读取 Session 检出的某个 `ForgeLocalScope`;`changeRequestDiff` 经提供方的 `diff` 读取已绑定变更请求在其最新状态 head 处的变更;两者返回的文件都已在宿主上解析为 `ForgeDiffFile` 的 hunk。`localText` 与 `changeRequestText` 读取一个文件的新版本,用来补出 hunk 之间未变更的行。每次检查还会把 `branch` 范围的规模统计为 `ForgePanelState.local`,供面板的变更卡片使用。
```ts type-equiv
@@ -785,6 +811,20 @@ Serves the forge panel. Following a Session inspects the checkout of its current
*/
@Remote({ mode: 'stream' }) async *follow(session: Session, signal: AbortSignal): AsyncIterable<ForgePanelState>
/**
* Stream the last known state of every change request that a listed
* Session the caller may read binds: the stored states first, then each
* state a poll changes or a new binding reveals. Listing reads no Session
* log: loaded Sessions report their bindings, others the cached Session
* summary, and a Session without one shows nothing until it is opened.
* Closed requests are polled at the idle cadence so reopening is observed. The
* stream ends when `signal` aborts or the Remote stops.
* @param signal - ends the stream.
* @returns frames in publication order.
* @throws when the Host lacks the Session query, projection cache, storage, or Session store service.
*/
@Remote({ mode: 'stream' }) async *changeRequestStates(signal: AbortSignal): AsyncIterable<ForgeChangeRequestStatesFrame>
/**
* List the local branches that can start a worktree of the Session's current checkout.
* @param session - Session whose current directory is listed.
@@ -9,29 +9,29 @@
en: 8ca3d802a44c6fd1
zh: 2723c9e28ecea7f7
/deepseek-ai-dsh-experimental-client-ui-forge/summary:
en: 6917eb21c7be3560
zh: dcb544919429b88a
en: 82f4b3ccb87e8466
zh: 3d3e24278917e9e9
/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: 78c3c73b79f06bf8
zh: 631d7f6015788cb5
en: d5ee2fba953800fb
zh: 394866a4859f0cd7
/deepseek-ai-dsh-experimental-client-ui-forge/understand-the-implementation:
en: 7f7729a86ee189a7
zh: 89ce23adde609fde
en: a215aa0b8f2ea401
zh: 938998d52e6d3790
/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
en: 8dbddff9f5369ca0
zh: 59651db8ae490342
/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
en: a2f1864d15c5f9f6
zh: a42f98cc27e1adfc
/deepseek-ai-dsh-experimental-client-ui-forge/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 3bb0c20c93f16f98
@@ -9,7 +9,7 @@ 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.
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, checks code host sign-in, streams [bound change requests](../forge-session/README.md) with their polled status for one Session or for the Session list, reads 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, Session list icons, and toasts.
## Table of Contents
@@ -33,6 +33,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish
- **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.
- **Session list** — after a Session row's time, a branch glyph shows while the Session works in the worktree the panel created for it, and a glyph shows the state of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. The glyph of an open change request keeps the row's subdued color with a dot for running, passed, or failed checks; merged and closed ones keep their state colors. The row's hover card names the worktree branch and what it started from, then up to three change requests with their number, title, state, and checks, and how many more there are. A restart shows the states known before it until the next poll.
- **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. Visited changes tabs stay mounted across tab and Session switches, hiding the sidebar, and docking changes, preserving file folds, revealed large files, expanded context, layout, and scroll position without rereading solely for a switch. Closing the tab or reloading the application discards this progress. 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. Clicking the file name or directory in either diff source copies its repository-relative path and reports success or failure in a toast. 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.
@@ -51,7 +52,7 @@ Panel text follows the application language: Chinese, English, Japanese, Spanish
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.
A worktree request is a `plugin:client-ui-forge/worktree` record, folded by the `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`. The projection also records the Session's directory from its header and each `working-directory/change`; its client view, which the Session summary carries, is the created worktree while that directory is the worktree or lies below it, else null. `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.
@@ -60,13 +61,16 @@ Each inspection also decides whether the worktree is missing: the Session's dire
`localDiff` takes the fork point from the created worktree's `baseCommit` when the Session works in it, else, for each remote in the code host order, from `symbolic-ref --quiet --short refs/remotes/<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. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. 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.
`changeRequestStates` streams, for the Session list, the last known number, title, state, draft flag, checks rollup, review decision, and mergeability of every change request that a listed Session the caller may read binds: a `baseline` frame, then `changed` frames. One run serves every stream. Cancellation is observed before initialization and between frames; the last stream and plugin disposal await initialization and accepted writes. Binding updates received during listing override its initial snapshot. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of a loaded Session from `ctx.forgeSession` and of another from its cached `forgeChangeRequests` summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. It stores every changed state in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL and drops unbound stored states only when every listed Session has known bindings; a failed write is logged and the state stays in memory. The stream fails when the Host lacks the Session query, projection cache, storage, or Session store service.
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. One stream of change request states feeds the Session row icons in `sidebar.session.row.trailing` and the hover card section in `sidebar.session.row.hover` while any of them is mounted; both take a Session's worktree and bindings from its Session list summary values. 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. The tab type enables `keepMounted`, retaining visited bodies and their owning Session references until their tab records end. 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/states.ts`](src/states.ts) | Change request states for the Session list |
| [`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 |
@@ -79,6 +83,8 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t
| [`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 |
| [`src/client/SessionRowForge.tsx`](src/client/SessionRowForge.tsx) | Session row icons |
| [`src/client/SessionHoverForge.tsx`](src/client/SessionHoverForge.tsx) | Session row hover card section |
</details>
@@ -96,7 +102,7 @@ The browser entry mounts the Remote, then the UI. One stream per Session feeds t
<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.
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, the Session list, and the rest of the panel add nothing to model requests.
#### KV Cache effect
@@ -106,7 +112,8 @@ None for a new worktree, as its notice belongs to the first request of the Sessi
<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.
- **Polling while shown** — status is polled only while a Session list row or a surface of this package for the Session is mounted; there are no notifications for checks that finish meanwhile.
- **Unopened Sessions in the list** — the Session list reads the worktree and bindings of a Session that is not loaded from its cached summary: a Session without one shows neither, and one last used before this version shows its worktree only after it is opened.
- **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.
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
这个可选的双端包展示 Session 创建或推送的变更请求及其变更,并让新 Session 在自己的 Git worktree 中开始。宿主端是 `forgePanel` Remote:它以只读方式检查 Session 的检出、识别代码托管平台并检查登录状态,以流的形式推送 Session 的[已绑定变更请求](../forge-session/README.zh.md)及其轮询状态,读取本地变更与变更请求的 diff,并在第一条消息于 Session 的收件箱中等待时创建所请求的 worktree。客户端增加一个侧边栏标签页、一个变更视图、标题栏中的入口、一个新会话选项、一张创建卡片,以及操作结果的 toast 提示。
这个可选的双端包展示 Session 创建或推送的变更请求及其变更,并让新 Session 在自己的 Git worktree 中开始。宿主端是 `forgePanel` Remote:它以只读方式检查 Session 的检出,检查代码托管平台的登录状态,为单个 Session 或会话列表以流的形式推送[已绑定变更请求](../forge-session/README.zh.md)及其轮询状态,读取 diff,并在第一条消息于 Session 的收件箱中等待时创建所请求的 worktree。客户端增加一个侧边栏标签页、一个变更视图、标题栏中的入口、一个新会话选项、一张创建卡片、会话列表中的图标,以及 toast 提示。
## 目录
@@ -33,6 +33,7 @@ kind: "package-reference"
- **创建卡片**——创建 worktree 期间,输入框上方的卡片显示新分支以及“创建分支”“检出文件”两个步骤;“更多详情”会列出分支与基准。“取消”停止这次尝试。尝试被停止或失败后,消息继续等待,失败时卡片显示原因:“重试”换一个新分支名再试;“不使用工作树”撤回请求,消息在当前检出中继续发送。
- **工作树已被删除**——Session 所在的 worktree 在 Session 之外被删除后,输入框上方的卡片会说明这一点;此期间发送的消息会等待、不开始这一轮,直到该 worktree 被恢复或离开,卡片也会说明消息之后会接着发送。“恢复工作树”(标签页中的“恢复”与之相同)在原目录重新检出该分支;分支也已删除时,从它开始时的提交重新创建分支;恢复期间卡片显示“准备工作树”“检出文件”两个步骤,标签页中的“恢复”变为加载状态。“在项目目录继续”把 Session 移回源检出。此期间标签页顶部一行显示警示图标。
- **标题栏按钮**——Session 有已绑定的变更请求、或其目录位于 Git 检出中时出现,用来显示或隐藏标签页。打开状态的变更请求存在检查失败、被要求修改或冲突时,按钮带红点。
- **会话列表**——Session 在面板为它创建的 worktree 中工作时,会话行的时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求的状态,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查进行中、已通过或失败;已合并与已关闭的保留各自的状态颜色。会话行的悬浮卡片写出 worktree 分支及其起点,再列出最多三个变更请求的编号、标题、状态与检查,并说明还有多少个。重启后,下次轮询之前显示重启前已知的状态。
- **标签页**——顶部一行显示当前检出的分支(分离 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 多出几个提交。工具栏可以跳到某个文件、汇总变更行数、刷新,并在左右对比与单栏对比、自动换行与截断长行之间切换。访问过的变更标签页会在切换标签页与 Session、隐藏侧边栏及调整停靠位置时保持挂载,保留文件折叠、大文件展开、上下文展开、布局与滚动位置,不会仅因切换而重新读取。关闭标签页或重新加载应用后不保留这些进度。文件名在前,目录在后,用更小的浅色字显示,空间不够时目录先缩短;悬停时显示完整路径,重命名的文件显示新旧两条路径。两种 diff 来源中,点击文件名或目录都会复制仓库相对路径,并通过 toast 报告成功或失败。每个文件带有新文件、已删除、重命名的标记(重命名标记写出原文件名,只换了目录时写出原路径),“分支”范围下的未跟踪文件还标“未提交”;文件菜单可以复制路径,本地变更可在侧边栏打开整个文件,变更请求可在浏览器中查看该文件在 head 处的页面。hunk 之间未变更的行折叠成一行,点击后从文件的新版本展开:工作树、“已暂存”时为暂存区,或经托管平台读取的 head 提交;读取 diff 之后文件又有修改时不会展开,直到视图重新读取。二进制文件与超过 `maxDiffBytes` 的未跟踪文件只显示一条说明;hunk 超过 1,000 行的文件先显示为一条写出变更行数的折叠条,点击后才绘制;前面的文件累计变更超过 5,000 行后,其后的文件默认收起。刷新与页面刷新快捷键、每次导航、变更请求的 head 变化,以及“分支”范围的摘要变化时,视图都会重新读取;变更请求的视图会先等到给出 head 的状态再读取,除非没有提供方服务它或那次轮询失败。重新读取已显示的变更时,刷新图标会旋转。托管平台或其提供方因超过大小上限拒绝给出 diff 的变更请求(例如 GitHub 上超过 300 个文件的 diff)显示“变更太大,无法在这里显示”并提供“在浏览器中查看”;其他读取失败提供“重试”,变更请求还提供“在浏览器中查看”。变更请求的 diff 在缓存期间对每个 head 提交与目标分支只下载一次,因此在同一 head 下重新打开、刷新与导航都不会再从托管平台读取。
@@ -51,7 +52,7 @@ kind: "package-reference"
检查运行 `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`。
worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由 `forgeWorktree` 投影折叠:请求与撤回在第一个 `turn/start` 或创建之前生效;在第一个 `step/start` 之前,未完成请求的第一条 `created` 记录生效。该投影还从 Session 头部与每个 `working-directory/change` 记录 Session 的目录;它的客户端视图随 Session 摘要下发,在该目录就是所创建的 worktree 或位于其下时为该 worktree,否则为 null。一轮开始或 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 目录必须匹配记录的工作树。插件卸载会取消并等待所有创建、恢复与消息等待任务,然后释放面板读取器和投影。
@@ -60,13 +61,16 @@ worktree 请求是一条 `plugin:client-ui-forge/worktree` 记录,由仅宿主
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>` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的正文用 slot store 按标签页保存范围与布局选择,经 Remote 读取,并用共享的代码高亮器高亮各行;标签页标题显示变更图标与视图名称。
`changeRequestStates` 为会话列表推送调用方可读的、已列出的 Session 所绑定的每个变更请求的最近编号、标题、状态、草稿标记、检查汇总、评审结论与可合并性:先是一个 `baseline` 帧,之后是 `changed` 帧。所有流共用一次运行。初始化之前和帧之间均响应取消;最后一条流结束及插件卸载会等待初始化与已接受的写入完成。列举期间收到的绑定更新优先于初始快照。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的 `forgeChangeRequests` 摘要;它跟随已加载 Session 的绑定变化,并经 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态;写入失败时记录日志,状态仍保留在内存中。宿主缺少 Session 查询、投影缓存、存储或 Session 存储服务时,流以失败结束。
浏览器入口先挂载 Remote,再挂载界面。每个 Session 一条流,同时供给标签页、标题栏按钮、新会话选项与创建卡片;第一个订阅方出现时打开,最后一个离开时关闭,传输重置后重新打开,在 Session scope 保留期间保存最后的状态。释放该 scope 时关闭流并移除缓存状态。新会话选项填入 `conversation.hero.options`,worktree 卡片填入 `conversation.input.dock`。worktree 卡片显示正在进行的创建,有消息在等待时“取消”调用 `stopCreation`;或显示消息等待时失败或被停止的那次尝试,“重试”调用 `retryWorktree`,“不使用工作树”调用 `cancelWorktree`。标签页用 Remote 宿主信息中的宿主主目录缩短路径,每次传输重置后重新读取。标签页把自己是否可见报告给标题栏按钮的按下状态。标题栏按钮收起停靠的标签页,或关闭其浮动标签页。toast 位于 `shell.overlay` 条目中,因此比标签页存在得更久。只要 `sidebar.session.row.trailing` 中的会话行图标或 `sidebar.session.row.hover` 中的悬浮卡片部分有一个已挂载,就由同一条变更请求状态流供给它们;两者都从会话列表的摘要值中读取 Session 的 worktree 与绑定。变更请求卡片接收变更请求、它的快照与可选操作,其他界面可以复用。变更视图是 `forge-changes` 资源标签页类型:它的地址 `dsh-resource://forge-changes/<session>/local` 与 `dsh-resource://forge-changes/<session>/change/<url>` 指明来源,导航参数可以指定要滚动到的文件。标签页类型启用 `keepMounted`,将访问过的正文及其所属 Session 引用保留至标签页记录结束。它的正文用 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/states.ts`](src/states.ts) | 会话列表用的变更请求状态 |
| [`src/git.ts`](src/git.ts) | 受限的只读 Git 命令 |
| [`src/parse.ts`](src/parse.ts) | Git 输出解析与远端顺序 |
| [`src/diff.ts`](src/diff.ts) | 补丁解析、把未跟踪文件表示为新增,以及 numstat 记录 |
@@ -79,6 +83,8 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
| [`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 缺失卡片 |
| [`src/client/SessionRowForge.tsx`](src/client/SessionRowForge.tsx) | 会话行图标 |
| [`src/client/SessionHoverForge.tsx`](src/client/SessionHoverForge.tsx) | 会话行悬浮卡片中的部分 |
</details>
@@ -96,7 +102,7 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
<a id="model-experience"></a>
## 模型体验
间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。检查、状态、diff、恢复与面板的其余部分都不会给模型请求增加内容。
间接影响,经由工作目录服务与 Session 的对话:关于 Session 新 worktree 的通知会加入第一次请求的消息,“在项目目录继续”会为下一次请求排入同类通知,“创建变更请求”会发送一条请 Agent 创建变更请求的用户消息。检查、状态、diff、恢复、会话列表与面板的其余部分都不会给模型请求增加内容。
#### KV Cache 影响
@@ -106,7 +112,8 @@ Session 在所创建的 worktree 中工作时,`localDiff` 以它的 `baseCommi
<a id="known-limitations-and-deferred-work"></a>
- **状态只在面板中轮询**——只有本包为该 Session 挂载了某个界面时才会轮询状态;都未挂载时检查结束不会有通知。
- **只在显示时轮询**——只有会话列表的某一行或本包为该 Session 挂载了某个界面时才会轮询状态;其间检查结束不会有通知。
- **会话列表中未打开的 Session**——会话列表从缓存的摘要中读取未加载 Session 的 worktree 与绑定:没有缓存摘要的 Session 两者都不显示;此版本之前最后使用的 Session 要打开之后才显示其 worktree。
- **检查触发时机**——检出只在 Session 的工具结果与目录变化之后检查;Session 之外的改动要等到刷新或下一个工具结果才会出现。
- **保留检出**——面板只在 Session 的第一步之前创建 worktree,不删除已有的检出目录或分支;失败或被取消的尝试可能留下分支或检出。分支名取自 Session id 与随机数字,而不是 Session 标题,因为那时标题还不存在。
- **一轮进行中的消息**——Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留:所请求的 worktree 改为在第一步创建,失败时这一轮结束且不带这条消息;已删除的 worktree 不会被检查,工作目录服务会把 Session 移回项目目录。
@@ -68,7 +68,8 @@
"@deepseek-ai/dsh-util-values": "workspace:*",
"diff": "^9.0.0",
"zod": "^4.4.3",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-storage-domain": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -107,7 +108,10 @@
"clsx": "^2.1.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-timeout": "workspace:*"
"@deepseek-ai/dsh-timeout": "workspace:*",
"@deepseek-ai/dsh-session-projection-cache": "workspace:*",
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-api-remote-access": "workspace:*"
},
"files": [
"locale/*.json",
@@ -0,0 +1,72 @@
/**
* The forge section of a Session row's hover card: the worktree branch while
* the Session works in the worktree the panel created for it, and its change
* requests, most urgent first.
*/
import type { ReactNode } from 'react'
import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls the ui-workspace SlotMap merge (the Session row seats).
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import { ChangeRequestGlyph } from './glyphs.tsx'
import type { NS } from './locales.ts'
import { useSessionForgeFacts, type SessionListInjected } from './session-list.ts'
import { CHECKS_LABEL, rowChecks, stateLine } from './SessionRowForge.tsx'
import { TONE_LABEL } from './status.ts'
import css from './SessionList.module.css'
/** Change requests the hover card lists before naming how many more there are. */
export const HOVER_CHANGE_REQUEST_LIMIT = 3
/** Full props of the hover section. */
export type SessionHoverForgeProps =
PropsRuntime<'sidebar.session.row.hover'> & PropsLocale<typeof NS> & InjectFace<SessionListInjected>
/**
* Render the forge section of a Session's hover card.
* @param props - the row's Session, the shared hooks, and copy.
* @returns the section, or null for a Session without a worktree or change requests.
*/
export function SessionHoverForge({ sessionId, useSessions, useStates, t }: SessionHoverForgeProps): ReactNode {
const facts = useSessionForgeFacts(useSessions, useStates, sessionId)
if (facts.worktree === null && facts.changeRequests.length === 0) return null
const shown = facts.changeRequests.slice(0, HOVER_CHANGE_REQUEST_LIMIT)
const omitted = facts.changeRequests.length - shown.length
const worktree = facts.worktree
return (
<div className={css.hover} data-forge-session-hover="">
{worktree !== null && (
<div className={css.hoverItem}>
<span className={css.hoverIcon} aria-hidden="true"><IconBranchOutlineRegular size={12} /></span>
<span className={css.hoverBody}>
<span className={css.hoverBranch}>{worktree.branch}</span>
<span className={css.hoverNote}>{t('checkout.worktreeFrom', { base: worktree.from ?? worktree.baseCommit.slice(0, 7) })}</span>
</span>
</div>
)}
{shown.map((entry) => {
const checks = rowChecks(entry)
return (
<div className={css.hoverItem} key={entry.url} data-forge-session-hover-change={entry.tone}>
<span className={css.hoverIcon} data-tone={entry.tone} aria-hidden="true">
<ChangeRequestGlyph tone={entry.tone} tinted={false} size={12} />
</span>
<span className={css.hoverBody}>
<span className={css.hoverTitle}>{entry.state === undefined ? entry.url : `#${entry.state.number} ${entry.state.title}`}</span>
<span className={css.hoverNote}>
<span aria-hidden="true">
{t(TONE_LABEL[entry.tone])}
{checks !== 'none' && <span className={css.hoverSeparator}>·</span>}
{checks !== 'none' && <span data-checks={checks}>{t(CHECKS_LABEL[checks])}</span>}
</span>
<span className={css.visuallyHidden}>{stateLine(entry, t)}</span>
</span>
</span>
</div>
)
})}
{omitted > 0 && <div className={css.hoverMore}>{t('list.more', { count: omitted })}</div>}
</div>
)
}
@@ -0,0 +1,111 @@
/* One icon in the Session row's resting seat, sized to the row's 20px line. */
.icon {
position: relative;
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
height: 20px;
color: var(--dsw-alias-label-tertiary);
}
/* Checks outcome at the glyph's lower right, ringed in the sidebar fill so it parts from the strokes. */
.dot {
position: absolute;
right: -2px;
bottom: 3px;
width: 6px;
height: 6px;
border-radius: 50%;
corner-shape: round;
box-shadow: 0 0 0 1.5px var(--dsw-specific-sidebar-fill);
}
.dot[data-checks='failure'] {
background: var(--dsw-alias-state-error-primary);
}
.dot[data-checks='pending'] {
background: var(--dsw-alias-state-warn-primary);
}
.dot[data-checks='success'] {
background: var(--dsw-alias-state-success-primary);
}
/* Hover card section. The card's surface is #2C2C2E in both themes, so these
* are the card's literal label colors, as in the ui-workspace rows, and the
* state colors are the dark palette's steps. */
.hover {
display: flex;
flex-direction: column;
gap: 8px;
}
.hoverItem {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
}
.hoverIcon {
display: inline-flex;
flex: none;
align-items: center;
height: 16px;
color: #ADB2B8;
}
.hoverIcon[data-tone='merged'] {
color: var(--dsw-static-violet-400);
}
.hoverIcon[data-tone='closed'] {
color: var(--dsw-static-red-400);
}
.hoverBody {
display: flex;
flex-direction: column;
min-width: 0;
}
.hoverBranch,
.hoverTitle {
overflow: hidden;
color: #FFFFFF;
font-size: 12px;
line-height: 16px;
text-overflow: ellipsis;
white-space: nowrap;
}
.hoverBranch {
font-family: var(--ds-font-family-code);
}
.hoverNote,
.hoverMore {
color: #ADB2B8;
font-size: 12px;
line-height: 16px;
}
.hoverSeparator {
margin: 0 4px;
}
.hoverNote [data-checks='failure'] {
color: var(--dsw-static-red-400);
}
/* The state and checks as one phrase for assistive technology, which skips the separator glyph. */
.visuallyHidden {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
@@ -0,0 +1,85 @@
/**
* Session row icons after the time label: a branch glyph while the Session
* works in the worktree the panel created for it, and the state glyph of its
* most urgent change request. Glyphs of open change requests stay in the
* row's subdued color with a dot for their checks; merged and closed ones keep
* their state color.
*/
import type { ReactNode } from 'react'
import { IconBranchOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls the ui-workspace SlotMap merge (the Session row seats).
import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { ChecksRollup } from '@deepseek-ai/dsh-experimental-forge/types'
import { ChangeRequestGlyph } from './glyphs.tsx'
import type { ForgePanelKey, NS } from './locales.ts'
import { useSessionForgeFacts, type SessionListChangeRequest, type SessionListInjected } from './session-list.ts'
import { TONE_LABEL } from './status.ts'
import css from './SessionList.module.css'
type Translate = PropsLocale<typeof NS>['t']
/** Copy of each checks outcome a row and its hover card name. */
export const CHECKS_LABEL: Record<Exclude<ChecksRollup, 'none'>, ForgePanelKey> = {
pending: 'change.ci.running',
success: 'change.ci.passed',
failure: 'change.ci.failedUnlisted',
}
/**
* The checks outcome a row shows for a change request: none for a merged,
* closed, or not yet polled one.
* @param entry - the change request.
* @returns the outcome.
*/
export function rowChecks(entry: SessionListChangeRequest): ChecksRollup {
if (entry.state === undefined || entry.tone === 'merged' || entry.tone === 'closed') return 'none'
return entry.state.checksRollup
}
/**
* Name a change request's state and checks, as one line.
* @param entry - the change request.
* @param t - forge panel copy.
* @returns the line.
*/
export function stateLine(entry: SessionListChangeRequest, t: Translate): string {
const state = t(TONE_LABEL[entry.tone])
const checks = rowChecks(entry)
return checks === 'none' ? state : t('list.stateChecks', { state, checks: t(CHECKS_LABEL[checks]) })
}
/** Full props of the row icons. */
export type SessionRowForgeProps =
PropsRuntime<'sidebar.session.row.trailing'> & PropsLocale<typeof NS> & InjectFace<SessionListInjected>
/**
* Render a Session row's forge icons.
* @param props - the row's Session, the shared hooks, and copy.
* @returns the icons, or nothing for a Session without a worktree or change requests.
*/
export function SessionRowForge({ sessionId, useSessions, useStates, t }: SessionRowForgeProps): ReactNode {
const facts = useSessionForgeFacts(useSessions, useStates, sessionId)
const top = facts.changeRequests[0]
return (
<>
{facts.worktree !== null && (
<span className={css.icon} role="img" aria-label={t('list.worktree')} data-forge-row-worktree="">
<IconBranchOutlineRegular size={13} />
</span>
)}
{top !== undefined && (
<span
className={css.icon}
role="img"
aria-label={t('change.stateAria', { state: stateLine(top, t) })}
data-forge-row-change={top.tone}
>
<ChangeRequestGlyph tone={top.tone} tinted={top.tone === 'merged' || top.tone === 'closed'} size={14} />
{rowChecks(top) !== 'none' && <span className={css.dot} data-checks={rowChecks(top)} />}
</span>
)}
</>
)
}
@@ -40,6 +40,9 @@ export const zh = {
'change.state.merged': '已合并',
'change.state.closed': '已关闭',
'change.state.unknown': '未知',
'list.worktree': '在自己的工作树中',
'list.stateChecks': '{state} · {checks}',
'list.more': '还有 {count} 个变更请求',
'change.expand': '展开 {name}',
'change.collapse': '收起 {name}',
'change.ci.none': '没有检查',
@@ -243,6 +246,9 @@ export const en: Record<ForgePanelKey, string> = {
'change.state.merged': 'Merged',
'change.state.closed': 'Closed',
'change.state.unknown': 'Unknown',
'list.worktree': 'In its own worktree',
'list.stateChecks': '{state} · {checks}',
'list.more': '{count} more change requests',
'change.expand': 'Expand {name}',
'change.collapse': 'Collapse {name}',
'change.ci.none': 'No checks',
@@ -415,6 +421,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
/** Japanese copy with the same keys. */
export const ja: Record<ForgePanelKey, string> = {
'list.worktree': '専用のワークツリー内',
'list.stateChecks': '{state} · {checks}',
'list.more': '他 {count} 件の変更リクエスト',
'tab.title': '変更リクエスト',
'header.label': 'このセッションの変更リクエスト',
'header.attention': 'このセッションの変更リクエストに対応が必要です',
@@ -615,6 +624,9 @@ export const ja: Record<ForgePanelKey, string> = {
/** Spanish copy with the same keys. */
export const es: Record<ForgePanelKey, string> = {
'list.worktree': 'En su propio árbol de trabajo',
'list.stateChecks': '{state} · {checks}',
'list.more': '{count} solicitudes de cambio más',
'tab.title': 'Solicitudes de cambio',
'header.label': 'Solicitudes de cambio de esta sesión',
'header.attention': 'Las solicitudes de cambio de esta sesión requieren atención',
@@ -815,6 +827,9 @@ export const es: Record<ForgePanelKey, string> = {
/** French copy with the same keys. */
export const fr: Record<ForgePanelKey, string> = {
'list.worktree': 'Dans son propre arbre de travail',
'list.stateChecks': '{state} · {checks}',
'list.more': '{count} demandes de changement supplémentaires',
'tab.title': 'Demandes de modification',
'header.label': 'Demandes de modification de cette session',
'header.attention': 'Les demandes de modification de cette session nécessitent une intervention',
@@ -1015,6 +1030,9 @@ export const fr: Record<ForgePanelKey, string> = {
/** German copy with the same keys. */
export const de: Record<ForgePanelKey, string> = {
'list.worktree': 'Im eigenen Arbeitsbaum',
'list.stateChecks': '{state} · {checks}',
'list.more': '{count} weitere Änderungsanfragen',
'tab.title': 'Änderungsanfragen',
'header.label': 'Änderungsanfragen dieser Sitzung',
'header.attention': 'Änderungsanfragen dieser Sitzung erfordern Aufmerksamkeit',
@@ -1215,6 +1233,9 @@ export const de: Record<ForgePanelKey, string> = {
/** Russian copy with the same keys. */
export const ru: Record<ForgePanelKey, string> = {
'list.worktree': 'В собственном рабочем дереве',
'list.stateChecks': '{state} · {checks}',
'list.more': 'Ещё запросов на изменение: {count}',
'tab.title': 'Запросы на изменение',
'header.label': 'Запросы на изменение этого сеанса',
'header.attention': 'Запросы на изменение этого сеанса требуют внимания',
@@ -1415,6 +1436,9 @@ export const ru: Record<ForgePanelKey, string> = {
/** Portuguese copy with the same keys. */
export const pt: Record<ForgePanelKey, string> = {
'list.worktree': 'Na sua própria árvore de trabalho',
'list.stateChecks': '{state} · {checks}',
'list.more': 'Mais {count} solicitações de alteração',
'tab.title': 'Solicitações de alteração',
'header.label': 'Solicitações de alteração desta sessão',
'header.attention': 'As solicitações de alteração desta sessão precisam de atenção',
@@ -13,7 +13,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-workspace/client'
import type {} from '@deepseek-ai/dsh-experimental-client-ui-forge/remote'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { RemoteResult, TypertRemoteContribution } from '@deepseek-ai/dsh-typert-protocol'
import type { ForgePanelChangeRequest } from '../types.ts'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelChangeRequest } from '../types.ts'
import { ActionToast } from './ActionToast.tsx'
import { CHANGES_ID, changesDefinition } from './changes-address.ts'
import { createChangesStore } from './changes-store.ts'
@@ -23,8 +23,11 @@ import { ForgeTab, type ForgeTabInjected } from './ForgeTab.tsx'
import { ForgeTabTitle } from './ForgeTabTitle.tsx'
import { HeaderButton, type HeaderButtonInjected } from './HeaderButton.tsx'
import { de, en, es, fr, ja, NS, pt, ru, zh, type ForgePanelKey } from './locales.ts'
import type { SessionListInjected } from './session-list.ts'
import { SessionHoverForge } from './SessionHoverForge.tsx'
import { SessionRowForge } from './SessionRowForge.tsx'
import {
createNoticeSource, createPanelSource, createVisibilitySource, type PanelSource, type VisibilitySource,
createNoticeSource, createPanelSource, createStreamSource, createVisibilitySource, foldStates, type PanelSource, type VisibilitySource,
} from './source.ts'
import { numberOf } from './status.ts'
import { WorktreeProgress, type WorktreeProgressInjected } from './WorktreeProgress.tsx'
@@ -263,6 +266,19 @@ function registerUi(ctx: Context): void {
name: 'shell.overlay', id: 'forge-panel.toast', locale: NS,
inject: () => ({ hooks: notices.hooks, dismiss: notices.dismiss }),
}, ActionToast))
// One stream of change request states serves every Session row while any row reads it.
const states = createStreamSource<ForgeChangeRequestStatesFrame, ReadonlyMap<string, ForgeChangeRequestListState>>({
open: signal => ctx.remote.forgePanel.changeRequestStates(signal),
subscribeReset: listener => ctx.on('connection/reset', listener),
}, new Map(), foldStates)
ctx.effect(() => () => { states.close() }, 'forge-panel: change request states')
const listFace = (): SessionListInjected => ({ hooks: { states: states.observable } })
ctx.slots.inject('sidebar.session.row.trailing', () => ctx.slots.register({
name: 'sidebar.session.row.trailing', id: 'forge-panel.row', order: 10, locale: NS, inject: listFace,
}, SessionRowForge))
ctx.slots.inject('sidebar.session.row.hover', () => ctx.slots.register({
name: 'sidebar.session.row.hover', id: 'forge-panel.row', order: 20, locale: NS, inject: listFace,
}, SessionHoverForge))
}
/**
@@ -0,0 +1,79 @@
/** The forge facts a Session row shows, from the Session list and the change request states every row shares. */
import { useMemo } from 'react'
import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
import type { UseSessions } from '@deepseek-ai/dsh-client-ui-session/client'
import type {} from '@deepseek-ai/dsh-experimental-forge-session/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types'
import type { ForgeChangeRequestListState, ForgeSessionWorktree } from '../types.ts'
import type { StreamSnapshot } from './source.ts'
import { toneOf, type ChangeRequestTone } from './status.ts'
/** Change request states by URL as the Host streams them. */
export type ChangeRequestStates = StreamSnapshot<ReadonlyMap<string, ForgeChangeRequestListState>>
/** Business face of the Session row entries: the change request states every row shares. */
export interface SessionListInjected {
readonly hooks: {
/** States by URL; the renderer binds it as `useStates`. */
readonly states: HostObservable<ChangeRequestStates>
}
}
/** One visible change request of a Session with its last polled state, when known. */
export interface SessionListChangeRequest {
readonly url: string
readonly state: ForgeChangeRequestListState | undefined
readonly tone: ChangeRequestTone
}
/** What a Session row and its hover card show. */
export interface SessionForgeFacts {
/** The worktree the panel created for the Session, while the Session works in it. */
readonly worktree: ForgeSessionWorktree | null
/** Visible change requests, most urgent first; equally urgent ones keep their binding order. */
readonly changeRequests: readonly SessionListChangeRequest[]
}
const URGENCY: Record<ChangeRequestTone, number> = { blocked: 0, open: 1, draft: 2, unknown: 3, merged: 4, closed: 5 }
const NO_FACTS: SessionForgeFacts = { worktree: null, changeRequests: [] }
/**
* Derive a Session's facts from its listed projection values.
* @param values - the Session's projection values in the Session list, undefined before it is listed.
* @param states - change request states by URL.
* @returns the facts; a Session without a worktree or change requests shares one empty value.
*/
export function sessionForgeFacts(
values: Readonly<Partial<SessionProjectionSummaryValues>> | undefined,
states: ReadonlyMap<string, ForgeChangeRequestListState>,
): SessionForgeFacts {
const worktree = values?.forgeWorktree ?? null
const changeRequests = (values?.forgeChangeRequests ?? [])
.filter(binding => !binding.dismissed)
.map((binding): SessionListChangeRequest => {
const state = states.get(binding.url)
return { url: binding.url, state, tone: toneOf(state ?? null) }
})
.sort((left, right) => URGENCY[left.tone] - URGENCY[right.tone])
return worktree === null && changeRequests.length === 0 ? NO_FACTS : { worktree, changeRequests }
}
/**
* Read one Session's facts, recomputed only when its listed values or the shared states change.
* @param useSessions - selector hook over the Session list.
* @param useStates - selector hook over the change request states.
* @param sessionId - the row's Session.
* @returns the facts.
*/
export function useSessionForgeFacts(
useSessions: UseSessions,
useStates: SnapshotSelectorHook<ChangeRequestStates>,
sessionId: SessionId,
): SessionForgeFacts {
const values = useSessions(state => state.byId[sessionId]?.projectionValues)
const states = useStates(snapshot => snapshot.state)
return useMemo(() => sessionForgeFacts(values, states), [values, states])
}
@@ -1,31 +1,40 @@
/** Per-Session panel stream sources, the store behind action notices, and the tab visibility store. */
/**
* Host stream sources (per-Session panel states and the change request states
* of the Session list), the store behind action notices, and the tab visibility store.
*/
import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots'
import type { ForgePanelState } from '../types.ts'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelState } from '../types.ts'
import type { ForgePanelKey } from './locales.ts'
/** What the panel surfaces of one Session read. */
export interface PanelSnapshot {
/** Latest state from the Host, or null before the first one arrives. */
readonly state: ForgePanelState | null
/** What a stream source publishes: the value its frames built, and whether the stream ended while read. */
export interface StreamSnapshot<S> {
/** Value built from the frames so far. */
readonly state: S
/** Whether the stream ended while surfaces still read it. */
readonly disconnected: boolean
}
/** One open stream of panel states. */
export interface PanelStream extends AsyncIterable<ForgePanelState> {
/** What the panel surfaces of one Session read; `state` is null before the first one arrives. */
export type PanelSnapshot = StreamSnapshot<ForgePanelState | null>
/** One open Host stream. */
export interface HostStream<F> extends AsyncIterable<F> {
/** Close the stream. */
dispose: () => void
}
/** Stream opener and connection-reset subscription of one Session's source. */
export interface PanelSourceDeps {
/** One open stream of panel states. */
export type PanelStream = HostStream<ForgePanelState>
/** Stream opener and connection-reset subscription of a source. */
export interface StreamSourceDeps<F> {
/**
* Open the Host stream of the Session.
* Open the Host stream.
* @param signal - aborts the stream.
* @returns the stream.
*/
readonly open: (signal: AbortSignal) => PanelStream
readonly open: (signal: AbortSignal) => HostStream<F>
/**
* Subscribe to transport resets, after which an open stream no longer delivers.
* @param listener - called on each reset.
@@ -34,6 +43,19 @@ export interface PanelSourceDeps {
readonly subscribeReset: (listener: () => void) => () => void
}
/** Stream opener and connection-reset subscription of one Session's panel source. */
export type PanelSourceDeps = StreamSourceDeps<ForgePanelState>
/** A value built from a Host stream that runs only while a surface subscribes. */
export interface StreamSource<S> {
/** Snapshot observable; the renderer binds it as a selector hook. */
readonly observable: HostObservable<StreamSnapshot<S>>
/** Open the stream again after it ended; a no-op while it runs or nobody subscribes. */
readonly reconnect: () => void
/** Close the stream and the reset subscription and drop every subscriber. */
readonly close: () => void
}
/** Panel state of one Session, streamed only while a surface subscribes. */
export interface PanelSource {
/** Snapshot observable; the renderer binds it as `usePanel`. */
@@ -44,22 +66,23 @@ export interface PanelSource {
readonly close: () => void
}
const EMPTY: PanelSnapshot = { state: null, disconnected: false }
/**
* Create the panel source of one Session. The first subscriber opens the
* stream and the last one closes it; the last state stays readable for the
* next subscriber. A stream that ends or fails while subscribed marks the
* snapshot disconnected until `reconnect` or a transport reset reopens it.
* Create a source that folds a Host stream into one value. The first
* subscriber opens the stream and the last one closes it; the value stays
* readable for the next subscriber. A stream that ends or fails while
* subscribed marks the snapshot disconnected until `reconnect` or a transport
* reset reopens it.
* @param deps - stream opener and reset subscription.
* @param initial - value before the first frame.
* @param fold - the value after one frame.
* @returns the source.
*/
export function createPanelSource(deps: PanelSourceDeps): PanelSource {
let snapshot = EMPTY
export function createStreamSource<F, S>(deps: StreamSourceDeps<F>, initial: S, fold: (state: S, frame: F) => S): StreamSource<S> {
let snapshot: StreamSnapshot<S> = { state: initial, disconnected: false }
const listeners = new Set<() => void>()
let running: { readonly controller: AbortController; readonly stream: PanelStream } | undefined
let running: { readonly controller: AbortController; readonly stream: HostStream<F> } | undefined
let stopReset: (() => void) | undefined
const publish = (next: PanelSnapshot): void => {
const publish = (next: StreamSnapshot<S>): void => {
snapshot = next
for (const listener of [...listeners]) listener()
}
@@ -76,9 +99,9 @@ export function createPanelSource(deps: PanelSourceDeps): PanelSource {
if (snapshot.disconnected) publish({ ...snapshot, disconnected: false })
void (async () => {
try {
for await (const state of opened.stream) {
for await (const frame of opened.stream) {
if (running !== opened) return
publish({ state, disconnected: false })
publish({ state: fold(snapshot.state, frame), disconnected: false })
}
} catch (error) {
void error // A failed stream is reported as the disconnection below.
@@ -99,23 +122,21 @@ export function createPanelSource(deps: PanelSourceDeps): PanelSource {
stop()
}
return {
hooks: {
panel: {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener)
if (listeners.size === 1) {
stopReset = deps.subscribeReset(() => {
stop()
reconnect()
})
start()
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) close()
}
},
observable: {
getSnapshot: () => snapshot,
subscribe(listener) {
listeners.add(listener)
if (listeners.size === 1) {
stopReset = deps.subscribeReset(() => {
stop()
reconnect()
})
start()
}
return () => {
listeners.delete(listener)
if (listeners.size === 0) close()
}
},
},
reconnect,
@@ -123,6 +144,32 @@ export function createPanelSource(deps: PanelSourceDeps): PanelSource {
}
}
/**
* Create the panel source of one Session: the latest state the Host streamed.
* @param deps - stream opener and reset subscription.
* @returns the source.
*/
export function createPanelSource(deps: PanelSourceDeps): PanelSource {
const source = createStreamSource<ForgePanelState, ForgePanelState | null>(deps, null, (_previous, state) => state)
return { hooks: { panel: source.observable }, reconnect: source.reconnect, close: source.close }
}
/**
* The known change request states after one `changeRequestStates` frame: a
* baseline replaces them, and a change replaces the states it carries.
* @param states - states by URL before the frame.
* @param frame - the frame.
* @returns states by URL after the frame.
*/
export function foldStates(
states: ReadonlyMap<string, ForgeChangeRequestListState>,
frame: ForgeChangeRequestStatesFrame,
): ReadonlyMap<string, ForgeChangeRequestListState> {
const next = frame.type === 'baseline' ? new Map<string, ForgeChangeRequestListState>() : new Map(states)
for (const state of frame.states) next.set(state.url, state)
return next
}
/** One action notice; `seq` keys the toast so a repeated notice restarts it. */
export interface ActionNotice {
/** Dictionary key of the notice text. */
@@ -45,15 +45,18 @@ const SETTLED: Record<Exclude<ChecksRollup, 'failure'>, Exclude<CiSummary['kind'
/** The most urgent fact of an open change request that the collapsed card names. */
export type ChangeRequestFlag = 'changesRequested' | 'conflicting' | 'draft'
/** The facts of a change request that decide its tone. */
export type ToneFacts = Pick<ChangeRequestSnapshot, 'state' | 'draft' | 'checksRollup' | 'reviewDecision' | 'mergeability'>
/**
* Pick the glyph tone of a change request: an open draft is always `draft`;
* any other open one is `blocked` while its checks failed, changes were
* requested, or it conflicts with its base, and running checks, a pending
* review, and an unknown mergeability only wait and keep it `open`.
* @param snapshot - latest snapshot, or null before the first one.
* @param snapshot - latest snapshot or listed state, or null before the first one.
* @returns the tone.
*/
export function toneOf(snapshot: ChangeRequestSnapshot | null): ChangeRequestTone {
export function toneOf(snapshot: ToneFacts | null): ChangeRequestTone {
if (snapshot === null) return 'unknown'
if (snapshot.state !== 'open') return snapshot.state
if (snapshot.draft) return 'draft'
@@ -3,6 +3,7 @@
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-api-remote-access'
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
import type { ForgeActionFailure, ForgeFailure, MergeMethod } from '@deepseek-ai/dsh-experimental-forge'
import type { SessionChangeRequest } from '@deepseek-ai/dsh-experimental-forge-session'
@@ -10,6 +11,8 @@ import type {} from '@deepseek-ai/dsh-experimental-worktree'
import { appendPluginRecord } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import type {} from '@deepseek-ai/dsh-session-projection-cache'
import type {} from '@deepseek-ai/dsh-session-query'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { gitIn, inspectCheckout, listBranches } from './git.ts'
@@ -18,10 +21,11 @@ import { parseDiff } from './diff.ts'
import { aheadOf, DiffTooLarge, forkPoint, localDiff, localText, NoForkPoint } from './local-diff.ts'
import { missingWorktree, SessionPanel } from './panel.ts'
import { PatchCache } from './patch-cache.ts'
import { ChangeRequestStates, changeRequestStatesDomain } from './states.ts'
import type { PanelConfig } from './panel.ts'
import type {
Config, ForgeBranches, ForgeChangeRequestDiff, ForgeLocalDiff, ForgeLocalScope, ForgeMissingWorktree, ForgePanelState,
ForgeSessionWorktree, ForgeWorktreeState,
Config, ForgeBranches, ForgeChangeRequestDiff, ForgeChangeRequestStatesFrame, ForgeLocalDiff, ForgeLocalScope, ForgeMissingWorktree,
ForgePanelState, ForgeSessionWorktree, ForgeWorktreeState,
} from './types.ts'
import { forgeWorktreeProjection, messageOf, within, WORKTREE_RECORD, WorktreeCreations } from './worktree.ts'
import type { HeldWork } from './worktree.ts'
@@ -77,7 +81,9 @@ export class ForgePanelController extends TypertRemoteService {
private worktreeStates!: ProjectionHandle<ForgeWorktreeState>
private creations!: WorktreeCreations
private readonly patches: PatchCache
private states: ChangeRequestStates | undefined
private readonly stopPanels: () => Promise<void>
private readonly stopStates: () => Promise<void>
constructor(ctx: Context, config: Config) {
super(ctx, 'forgePanel')
@@ -101,6 +107,9 @@ export class ForgePanelController extends TypertRemoteService {
this.panels.clear()
await Promise.allSettled(panels.map(panel => panel.dispose()))
}, 'forge-panel: stop panels')
this.stopStates = ctx.effect(() => async () => {
await this.states?.dispose()
}, 'forge-panel: stop change request states')
}
protected async [Service.init](): Promise<void> {
@@ -125,13 +134,14 @@ export class ForgePanelController extends TypertRemoteService {
if (name !== 'worktrees') return
for (const panel of this.panels.values()) panel.updateWorktree()
})
const { creations, stopPanels } = this
const { creations, stopPanels, stopStates } = this
ctx.effect(function* () {
yield stateOwner.dispose
yield inbox
yield events
yield services
yield stopPanels
yield stopStates
yield creations.dispose
}, 'forge-panel: drain before releasing state')
}
@@ -149,6 +159,24 @@ export class ForgePanelController extends TypertRemoteService {
yield* this.panelOf(session).follow(signal)
}
/**
* Stream the last known state of every change request that a listed
* Session the caller may read binds: the stored states first, then each
* state a poll changes or a new binding reveals. Listing reads no Session
* log: loaded Sessions report their bindings, others the cached Session
* summary, and a Session without one shows nothing until it is opened.
* Closed requests are polled at the idle cadence so reopening is observed. The
* stream ends when `signal` aborts or the Remote stops.
* @param signal - ends the stream.
* @returns frames in publication order.
* @throws when the Host lacks the Session query, projection cache, storage, or Session store service.
*/
@Remote({ mode: 'stream' })
async *changeRequestStates(signal: AbortSignal): AsyncIterable<ForgeChangeRequestStatesFrame> {
const access = this.ctx.get('remoteAccess')
yield* this.changeRequestStatesHub().stream(id => access === undefined || access.allows({ kind: 'session', id }), signal)
}
/**
* List the local branches that can start a worktree of the Session's current checkout.
* @param session - Session whose current directory is listed.
@@ -565,6 +593,36 @@ export class ForgePanelController extends TypertRemoteService {
await running
}
/** The shared change request states, created for the first stream from the Host's Session and storage services. */
private changeRequestStatesHub(): ChangeRequestStates {
if (this.states !== undefined) return this.states
const ctx = this.home
const query = ctx.get('sessionQuery')
const cache = ctx.get('sessionProjectionCache')
const storage = ctx.get('storageDomain')
const store = ctx.get('sessions')
if (query === undefined || cache === undefined || storage === undefined || store === undefined) {
throw new Error('forge-panel: change request states need the sessionQuery, sessionProjectionCache, storageDomain, and sessions services')
}
const visibleUrls = (session: Session): string[] => ctx.forgeSession.changeRequests(session).map(entry => entry.url)
this.states = new ChangeRequestStates({
sessions: async signal => (await query.listSessions(signal)).map(({ header }) => {
const live = store.get(header.id)
if (live !== undefined) return { id: header.id, urls: visibleUrls(live) }
const bindings = cache.cachedSnapshot(header, ['forgeChangeRequests'])?.values.forgeChangeRequests
return { id: header.id, urls: bindings?.filter(binding => !binding.dismissed).map(binding => binding.url) }
}),
onBindings: listener => ctx.sessionProjections.onChanged((session, key) => {
if (key === 'forgeChangeRequests') listener(session.id, visibleUrls(session))
}),
refOf: url => ctx.forge.changeRequestFromUrl(url),
watch: (ref, listener) => ctx.forge.watch(ref, listener),
open: async () => (await storage.open(changeRequestStatesDomain)).table('states'),
report: (error) => { ctx.logger.warn(`forge-panel: storing a change request state failed: ${String(error)}`) },
})
return this.states
}
private panelOf(session: Session): SessionPanel {
let panel = this.panels.get(session)
if (panel === undefined) {
@@ -0,0 +1,337 @@
/**
* The last known state of every change request that a listed Session binds,
* for the Session list. One run starts with the first stream and stops with
* the last: it reads the stored states, lists the Sessions with their
* bindings, follows binding changes of loaded Sessions, and polls each bound
* change request, once however many Sessions bind it. The forge service
* owns polling cadence and terminal-state refresh. Every state change is stored, so a restart shows it before the
* first poll.
*/
import { createHash } from 'node:crypto'
import { z } from 'zod'
import { defineDomain } from '@deepseek-ai/dsh-storage-domain'
import type { ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot } from '@deepseek-ai/dsh-experimental-forge/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame } from './types.ts'
const stateSchema: z.ZodType<ForgeChangeRequestListState> = z.object({
url: z.string().min(1),
number: z.number().int().positive(),
title: z.string(),
state: z.enum(['open', 'merged', 'closed']),
draft: z.boolean(),
checksRollup: z.enum(['none', 'pending', 'success', 'failure']),
reviewDecision: z.enum(['none', 'review-required', 'approved', 'changes-requested']),
mergeability: z.enum(['mergeable', 'conflicting', 'unknown']),
}).strict()
/** Stored states keyed by {@link storedKey}; derived data, so a record that fails its schema is set aside. */
export const changeRequestStatesDomain = defineDomain({
name: 'forge_panel_change_requests',
version: 1,
layout: 'per-record',
invalidRecords: 'backup-and-skip',
tables: { states: { valueSchema: stateSchema } },
})
/**
* The storage key of a change request's state: a path-safe digest of its URL.
* @param url - the change request's web URL.
* @returns the key.
*/
export function storedKey(url: string): string {
return createHash('sha256').update(url).digest('hex')
}
/** The stored states the run reads and writes. */
export interface StoredStates {
/** The state stored under a key. */
get(key: string): ForgeChangeRequestListState | undefined
/** Every stored state. */
entries(): IterableIterator<[string, ForgeChangeRequestListState]>
/** Store a state durably. */
put(key: string, value: ForgeChangeRequestListState): Promise<void>
/** Remove a stored state durably. */
delete(key: string): Promise<boolean>
}
/** One listed Session and the URLs of its visible bindings, undefined when they are not known without loading it. */
export interface ListedSession {
readonly id: SessionId
readonly urls: readonly string[] | undefined
}
/** What a run reads, follows, and polls. */
export interface ChangeRequestStateSources {
/** List every Session with its visible bindings. */
readonly sessions: (signal: AbortSignal) => Promise<readonly ListedSession[]>
/** Follow the visible bindings of loaded Sessions; returns the disposer. */
readonly onBindings: (listener: (id: SessionId, urls: readonly string[]) => void) => () => void
/** The change request a URL names, or undefined when no provider recognizes it. */
readonly refOf: (url: string) => ChangeRequestRef | undefined
/** Poll a change request until the returned disposer runs. */
readonly watch: (ref: ChangeRequestRef, listener: ChangeRequestListener) => () => void
/** Open the stored states. */
readonly open: () => Promise<StoredStates>
/** Report a failed durable write; the state stays in memory. */
readonly report: (error: unknown) => void
}
function listStateOf(snapshot: ChangeRequestSnapshot, url: string): ForgeChangeRequestListState {
return {
url,
number: snapshot.ref.number,
title: snapshot.title,
state: snapshot.state,
draft: snapshot.draft,
checksRollup: snapshot.checksRollup,
reviewDecision: snapshot.reviewDecision,
mergeability: snapshot.mergeability,
}
}
function sameState(left: ForgeChangeRequestListState | undefined, right: ForgeChangeRequestListState): boolean {
return left !== undefined && left.title === right.title && left.state === right.state && left.draft === right.draft
&& left.checksRollup === right.checksRollup && left.reviewDecision === right.reviewDecision
&& left.mergeability === right.mergeability && left.number === right.number
}
/** One stream's pending changes, coalesced by URL until the reader takes them. */
class Reader {
private pending = new Map<string, ForgeChangeRequestListState>()
private wake: (() => void) | undefined
private ended = false
private readonly completion = Promise.withResolvers<undefined>()
readonly closed = this.completion.promise
private readonly abort = (): void => { this.end() }
constructor(readonly allowed: (id: SessionId) => boolean, private readonly signal: AbortSignal) {
signal.addEventListener('abort', this.abort, { once: true })
}
push(state: ForgeChangeRequestListState): void {
if (this.ended) return
this.pending.set(state.url, state)
this.wake?.()
}
end(): void {
this.ended = true
this.pending.clear()
this.completion.resolve(undefined)
this.wake?.()
}
dispose(): void {
this.signal.removeEventListener('abort', this.abort)
this.end()
}
async *frames(): AsyncIterable<ForgeChangeRequestStatesFrame> {
while (!this.ended) {
if (this.pending.size === 0) {
await new Promise<void>((resolve) => { this.wake = resolve })
this.wake = undefined
continue
}
const states = [...this.pending.values()]
this.pending = new Map()
yield { type: 'changed', states }
}
}
}
/** The state of one run: stored and polled states, who binds what, and the polls. */
class Run {
private readonly states = new Map<string, ForgeChangeRequestListState>()
private readonly sessionsOf = new Map<string, Set<SessionId>>()
private readonly urlsOf = new Map<SessionId, ReadonlySet<string>>()
private readonly polls = new Map<string, () => void>()
private readonly stopBindings: () => void
private readonly startupBindings = new Map<SessionId, readonly string[]>()
private starting = true
private readonly writes = new Set<Promise<void>>()
private stopped = false
constructor(
private readonly sources: ChangeRequestStateSources,
private readonly readers: ReadonlySet<Reader>,
private readonly table: StoredStates,
) {
for (const [, state] of table.entries()) this.states.set(state.url, state)
// Subscribe before listing; updates received during the read supersede its snapshot.
this.stopBindings = sources.onBindings((id, urls) => {
if (this.starting) this.startupBindings.set(id, urls)
else this.bind(id, urls)
})
}
async start(signal: AbortSignal): Promise<void> {
const listed = await this.sources.sessions(signal)
signal.throwIfAborted()
const updates = this.startupBindings
for (const session of listed) {
const urls = updates.get(session.id) ?? session.urls
if (urls !== undefined) this.bind(session.id, urls)
}
this.starting = false
for (const [id, urls] of updates) this.bind(id, urls)
updates.clear()
// An uncached Session may still bind a stored request; absence is not established yet.
if (listed.some(session => session.urls === undefined)) return
for (const [url] of this.states) {
if (this.sessionsOf.has(url)) continue
this.states.delete(url)
this.write(() => this.table.delete(storedKey(url)))
}
}
/** The known states of every change request that some Session the reader may read binds. */
visible(allowed: (id: SessionId) => boolean): ForgeChangeRequestListState[] {
const states: ForgeChangeRequestListState[] = []
for (const [url, ids] of this.sessionsOf) {
const state = this.states.get(url)
if (state !== undefined && [...ids].some(allowed)) states.push(state)
}
return states
}
async stop(): Promise<void> {
this.stopped = true
this.stopBindings()
for (const stop of this.polls.values()) stop()
this.polls.clear()
await Promise.all(this.writes)
}
private bind(id: SessionId, urls: readonly string[]): void {
const next = new Set(urls)
const previous = this.urlsOf.get(id) ?? new Set<string>()
this.urlsOf.set(id, next)
for (const url of previous) {
if (next.has(url)) continue
const ids = this.sessionsOf.get(url)
ids?.delete(id)
if (ids !== undefined && ids.size === 0) {
this.sessionsOf.delete(url)
this.polls.get(url)?.()
this.polls.delete(url)
}
}
for (const url of next) {
if (previous.has(url)) continue
const ids = this.sessionsOf.get(url) ?? new Set<SessionId>()
ids.add(id)
this.sessionsOf.set(url, ids)
const state = this.states.get(url)
if (state !== undefined) for (const reader of this.readers) if (reader.allowed(id)) reader.push(state)
this.poll(url, ids)
}
}
/** Poll a bound change request; `ids` is the live set of Sessions binding it, which outlives the poll. */
private poll(url: string, ids: ReadonlySet<SessionId>): void {
if (this.polls.has(url)) return
const ref = this.sources.refOf(url)
if (ref === undefined) return
this.polls.set(url, this.sources.watch(ref, (update) => {
if ('failure' in update) return
this.settle(url, ids, listStateOf(update.snapshot, url))
}))
}
private settle(url: string, ids: ReadonlySet<SessionId>, state: ForgeChangeRequestListState): void {
if (this.stopped || sameState(this.states.get(url), state)) return
this.states.set(url, state)
this.write(() => this.table.put(storedKey(url), state))
for (const reader of this.readers) if ([...ids].some(reader.allowed)) reader.push(state)
}
private write(operation: () => Promise<unknown>): void {
const task = operation().then(() => undefined, (error: unknown) => { this.sources.report(error) })
this.writes.add(task)
void task.then(() => { this.writes.delete(task) })
}
}
/** Streams the states of the change requests that listed Sessions bind. */
export class ChangeRequestStates {
private readonly readers = new Set<Reader>()
private stopping: Promise<void> = Promise.resolve()
private disposed = false
private run: { readonly started: Promise<Run | undefined>; readonly aborter: AbortController } | undefined
/** @param sources - what runs read, follow, and poll. */
constructor(private readonly sources: ChangeRequestStateSources) {}
/**
* Stream the known states the reader may see first, then each state that
* changes or becomes visible to it. The stream ends when `signal` aborts or
* the hub is disposed.
* @param allowed - whether the reader may read a Session.
* @param signal - ends the stream.
* @returns frames in publication order.
*/
async *stream(allowed: (id: SessionId) => boolean, signal: AbortSignal): AsyncIterable<ForgeChangeRequestStatesFrame> {
signal.throwIfAborted()
if (this.disposed) return
const reader = new Reader(allowed, signal)
this.readers.add(reader)
try {
const run = await Promise.race([this.ensureRun(), reader.closed.then(() => undefined)])
// A run that the hub stopped while it started leaves nothing to read.
if (run === undefined) return
yield { type: 'baseline', states: run.visible(allowed) }
yield* reader.frames()
} finally {
reader.dispose()
this.readers.delete(reader)
if (this.readers.size === 0) await this.stopRun()
}
}
/**
* End every stream, stop polling, and wait for initialization and accepted storage writes.
* @returns settles after all owned work stops; subsequent streams end without starting work.
*/
async dispose(): Promise<void> {
this.disposed = true
for (const reader of this.readers) reader.end()
await this.stopRun()
}
private ensureRun(): Promise<Run | undefined> {
if (this.run !== undefined) return this.run.started
const aborter = new AbortController()
const preceding = this.stopping
const started = (async () => {
await preceding
aborter.signal.throwIfAborted()
const table = await this.sources.open()
aborter.signal.throwIfAborted()
const run = new Run(this.sources, this.readers, table)
try {
await run.start(aborter.signal)
return run
} catch (error) {
await run.stop()
throw error
}
})()
this.run = { started, aborter }
return started
}
private stopRun(): Promise<void> {
const current = this.run
if (current === undefined) return this.stopping
this.run = undefined
current.aborter.abort()
this.stopping = current.started.then(run => run?.stop(), (error: unknown) => {
void error // Initialization failure is reported to the stream that awaited it.
})
return this.stopping
}
}
@@ -70,6 +70,8 @@ export interface ForgeWorktreeState {
readonly locked: boolean
/** Whether a step has started; no worktree is created afterwards. */
readonly stepped: boolean
/** The Session's working directory as its log last recorded it, or null when none is recorded. */
readonly cwd: string | null
/** Whether a restoration started without a successful completion, including across reloads. */
readonly restoring: boolean
}
@@ -159,6 +161,26 @@ export interface ForgePanelWorktree {
readonly waiting: ForgeWorktreeWaiting | null
}
/**
* The last polled state of one change request that a listed Session binds, as
* the Session list shows it.
*/
export type ForgeChangeRequestListState = Pick<
ChangeRequestSnapshot, 'url' | 'title' | 'state' | 'draft' | 'checksRollup' | 'reviewDecision' | 'mergeability'
> & {
/** Change request number on its code host. */
readonly number: number
}
/**
* One frame of `forgePanel.changeRequestStates`: the known state of every
* change request the reader may see first, then the states that changed or
* became visible since the previous frame.
*/
export type ForgeChangeRequestStatesFrame =
| { readonly type: 'baseline'; readonly states: readonly ForgeChangeRequestListState[] }
| { readonly type: 'changed'; readonly states: readonly ForgeChangeRequestListState[] }
/** One bound change request in the panel state. */
export interface ForgePanelChangeRequest extends SessionChangeRequest {
/** Latest snapshot a poll returned, kept while later polls fail, or null before the first successful poll. */
@@ -363,3 +385,10 @@ declare module '@deepseek-ai/dsh-session/types' {
'plugin:client-ui-forge/worktree': WorktreeRecord
}
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** The worktree the panel created for the Session while the Session works in it, else null. */
forgeWorktree: ForgeSessionWorktree | null
}
}
@@ -9,6 +9,7 @@ import { appendPluginRecord, pluginRecordOf } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition, ProjectionHandle } from '@deepseek-ai/dsh-session-projection'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import type {} from '@deepseek-ai/dsh-working-directory'
import { z } from 'zod'
import type {
ForgeMissingWorktree, ForgeSessionWorktree, ForgeWorktreeCreation, ForgeWorktreeFailure, ForgeWorktreeRestoration, ForgeWorktreeState,
@@ -18,7 +19,7 @@ import type {
/** Plugin record type of worktree requests, withdrawals, and creations. */
export const WORKTREE_RECORD = 'plugin:client-ui-forge/worktree'
const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false, restoring: false }
const EMPTY: Omit<ForgeWorktreeState, 'cwd'> = { request: null, created: null, locked: false, stepped: false, restoring: false }
const worktreeSchema: z.ZodType<ForgeSessionWorktree> = z.object({
path: z.string().min(1),
@@ -42,21 +43,28 @@ const stateSchema: z.ZodType<ForgeWorktreeState> = z.object({
locked: z.boolean(),
stepped: z.boolean(),
restoring: z.boolean(),
cwd: z.string().min(1).nullable(),
}).strict()
/**
* The host-only `forgeWorktree` unit. `request` and `cancel` records change
* the request until the first `turn/start` or creation; the first `created`
* record of an open request applies until the first `step/start`. Records
* outside those periods leave the state unchanged. Restoration start and completion
* records retain whether the created checkout is unavailable across reloads.
* The `forgeWorktree` unit. `request` and `cancel` records change the request
* until the first `turn/start` or creation; the first `created` record of an
* open request applies until the first `step/start`. Records outside those
* periods leave the state unchanged. The unit also follows the Session's
* working directory, so clients, including the Session summary, see the
* created worktree only while that directory is the worktree or lies below it.
* Restoration records retain incomplete checkout recovery across reloads.
*/
export const forgeWorktreeProjection = {
key: 'forgeWorktree',
stateVersion: 3,
stateVersion: 4,
stateSchema,
init: () => EMPTY,
init: header => ({ ...EMPTY, cwd: header.cwd ?? null }),
apply,
wire: {
viewSchema: worktreeSchema.nullable(),
view: state => state.created !== null && state.cwd !== null && within(state.created.path, state.cwd) ? state.created : null,
},
} satisfies ProjectionDefinition<'forgeWorktree', ForgeWorktreeState>
/**
@@ -85,6 +93,8 @@ function apply(state: ForgeWorktreeState, event: SessionEvent): ForgeWorktreeSta
return state.locked ? state : { ...state, locked: true }
case 'step/start':
return state.stepped ? state : { ...state, stepped: true }
case 'working-directory/change':
return state.cwd === event.data.cwd ? state : { ...state, cwd: event.data.cwd }
// Every other event type, including plugin records, which sit outside SessionEventMap.
default:
return applyRecord(state, event)
@@ -5,8 +5,8 @@ import type {
} from '@deepseek-ai/dsh-experimental-forge/types'
import type { BindingOrigin } from '@deepseek-ai/dsh-experimental-forge-session/types'
import type {
ForgeCheckout, ForgePanelChangeRequest, ForgePanelState, ForgePanelWorktree, ForgeSessionWorktree, ForgeWorktreeCreation,
ForgeWorktreeFailure,
ForgeChangeRequestListState, ForgeCheckout, ForgePanelChangeRequest, ForgePanelState, ForgePanelWorktree, ForgeSessionWorktree,
ForgeWorktreeCreation, ForgeWorktreeFailure,
} from '../src/types.ts'
/** Pull request reference in `octo/demo` on github.com. */
@@ -45,6 +45,21 @@ export function snapshot(number: number, overrides: Partial<ChangeRequestSnapsho
}
}
/** The Session list's state of {@link snapshot}: open and mergeable, with passed checks and no review. */
export function listState(number: number, overrides: Partial<ForgeChangeRequestListState> = {}): ForgeChangeRequestListState {
return {
url: `https://github.com/octo/demo/pull/${number}`,
number,
title: `Change ${number}`,
state: 'open',
draft: false,
checksRollup: 'success',
reviewDecision: 'none',
mergeability: 'mergeable',
...overrides,
}
}
/** Check of the head commit that succeeded after running from 10:00:00 to 10:01:05. */
export function check(name: string, overrides: Partial<ChangeRequestCheck> = {}): ChangeRequestCheck {
return {
@@ -1601,7 +1601,7 @@ describe('forgePanel lifecycle', () => {
await opened.ctx.plugin(ForgePanelController, {})
expect(opened.ctx.sessionProjections.checkpoint(opened.session).forgeWorktree?.val)
.toEqual({ request: { from: null }, created: null, locked: false, stepped: false, restoring: false })
.toEqual({ request: { from: null }, created: null, locked: false, stepped: false, cwd: repo, restoring: false })
})
})
@@ -21,11 +21,16 @@ import { ForgeTabTitle } from '../src/client/ForgeTabTitle.tsx'
import { HeaderButton, type HeaderButtonInjected } from '../src/client/HeaderButton.tsx'
import { en, NS, zh } from '../src/client/locales.ts'
import { inject, mountForgePanel, TAB_ID, TAB_KIND } from '../src/client/mount.ts'
import type { SessionListInjected } from '../src/client/session-list.ts'
import { SessionHoverForge } from '../src/client/SessionHoverForge.tsx'
import { SessionRowForge } from '../src/client/SessionRowForge.tsx'
import type { NoticeSource } from '../src/client/source.ts'
import { WorktreeProgress, type WorktreeProgressInjected } from '../src/client/WorktreeProgress.tsx'
import { WorktreeToggle, type WorktreeToggleInjected } from '../src/client/WorktreeToggle.tsx'
import type { ForgeBranches, ForgeChangeRequestDiff, ForgeLocalDiff, ForgeLocalScope, ForgePanelState } from '../src/types.ts'
import { entry, ready, snapshot, unserved } from './client-fixtures.client.ts'
import type {
ForgeBranches, ForgeChangeRequestDiff, ForgeChangeRequestStatesFrame, ForgeLocalDiff, ForgeLocalScope, ForgePanelState,
} from '../src/types.ts'
import { entry, listState, ready, snapshot, unserved } from './client-fixtures.client.ts'
const REMOTE: TypertRemoteContribution = { package: '@deepseek-ai/dsh-experimental-client-ui-forge', descriptors: [] }
const S1 = SessionId('s1')
@@ -39,17 +44,17 @@ afterEach(() => {
Reflect.deleteProperty(navigator, 'clipboard')
})
/** A fake `follow` stream handle the test feeds by hand. */
function stream(signal: AbortSignal) {
const queue: ForgePanelState[] = []
/** A fake `follow` or `changeRequestStates` stream handle the test feeds by hand. */
function stream<F = ForgePanelState>(signal: AbortSignal) {
const queue: F[] = []
let wake: (() => void) | undefined
let closed = false
const handle = {
signal,
disposed: vi.fn(),
push(state: ForgePanelState) { queue.push(state); wake?.() },
push(frame: F) { queue.push(frame); wake?.() },
dispose() { handle.disposed(); closed = true; wake?.() },
async *[Symbol.asyncIterator](): AsyncIterator<ForgePanelState> {
async *[Symbol.asyncIterator](): AsyncIterator<F> {
while (true) {
const next = queue.shift()
if (next !== undefined) { yield next; continue }
@@ -98,6 +103,10 @@ function isChangesFace(value: Record<string, unknown>): value is Record<string,
return typeof value.localDiff === 'function' && typeof value.createChangeRequest === 'function'
}
function isListFace(value: Record<string, unknown>): value is Record<string, unknown> & SessionListInjected {
return typeof value.hooks === 'object' && value.hooks !== null && 'states' in value.hooks
}
function isNoticeFace(value: Record<string, unknown>): value is Record<string, unknown> & Omit<NoticeSource, 'report'> {
return typeof value.dismiss === 'function' && typeof value.hooks === 'object'
}
@@ -120,12 +129,18 @@ async function base() {
}
const remote = new Remote()
const streams: ReturnType<typeof stream>[] = []
const stateStreams: ReturnType<typeof stream<ForgeChangeRequestStatesFrame>>[] = []
const forgePanel = {
follow: vi.fn((_sessionId: SessionId, signal: AbortSignal) => {
const handle = stream(signal)
streams.push(handle)
return handle
}),
changeRequestStates: vi.fn((signal: AbortSignal) => {
const handle = stream<ForgeChangeRequestStatesFrame>(signal)
stateStreams.push(handle)
return handle
}),
track: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
dismiss: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
markReady: vi.fn(async (_sessionId: SessionId, _url: string): Promise<RemoteResult<void>> => OK),
@@ -172,9 +187,11 @@ async function base() {
'conversation.input.dock': { kind: 'list', scope: 'session' },
'conversation.input.left': { kind: 'list', scope: 'session' },
'shell.overlay': { kind: 'list', scope: 'root' },
'sidebar.session.row.trailing': { kind: 'list', scope: 'root', inline: true },
'sidebar.session.row.hover': { kind: 'list', scope: 'root' },
},
} as never, () => null)
return { ctx, remote, unmount, streams, forgePanel, sidebarRightTabs, unregisterTab, sidebarRight, sessions, scopes }
return { ctx, remote, unmount, streams, stateStreams, forgePanel, sidebarRightTabs, unregisterTab, sidebarRight, sessions, scopes }
}
async function fixture() {
@@ -190,8 +207,10 @@ async function fixture() {
const worktreeInject = entry('conversation.hero.options', WorktreeToggle)?.inject
const progressInject = entry('conversation.input.dock', WorktreeProgress)?.inject
const toastInject = entry('shell.overlay', ActionToast)?.inject
const rowInject = entry('sidebar.session.row.trailing', SessionRowForge)?.inject
const hoverInject = entry('sidebar.session.row.hover', SessionHoverForge)?.inject
assert(tabInject !== undefined && headerInject !== undefined && worktreeInject !== undefined && toastInject !== undefined)
assert(progressInject !== undefined && changesInject !== undefined)
assert(progressInject !== undefined && changesInject !== undefined && rowInject !== undefined && hoverInject !== undefined)
const tab = (sessionId: SessionId): ForgeTabInjected => {
const value = tabInject(sessionId as never)
assert(isTabFace(value))
@@ -223,7 +242,12 @@ async function fixture() {
return value
}
const notice = () => toast().hooks.notice.getSnapshot()
return { ...b, dispose, entry, tab, changes, header, worktree, progress, toast, notice }
const list = (place: 'row' | 'hover'): SessionListInjected => {
const value = (place === 'row' ? rowInject : hoverInject)(undefined as never)
assert(isListFace(value))
return value
}
return { ...b, dispose, entry, tab, changes, header, worktree, progress, toast, notice, list }
}
describe('mountForgePanel registration', () => {
@@ -250,6 +274,8 @@ describe('mountForgePanel registration', () => {
expect(f.entry('conversation.input.dock', WorktreeProgress)).toMatchObject({ options: { id: 'forge-panel.worktree', order: 5 }, locale: NS })
expect(f.ctx.slots.entries('conversation.input.left')).toEqual([])
expect(f.entry('shell.overlay', ActionToast)).toMatchObject({ options: { id: 'forge-panel.toast' }, locale: NS })
expect(f.entry('sidebar.session.row.trailing', SessionRowForge)).toMatchObject({ options: { id: 'forge-panel.row', order: 10 }, locale: NS })
expect(f.entry('sidebar.session.row.hover', SessionHoverForge)).toMatchObject({ options: { id: 'forge-panel.row', order: 20 }, locale: NS })
expect(f.notice()).toBeNull()
await f.dispose()
})
@@ -258,12 +284,16 @@ describe('mountForgePanel registration', () => {
const f = await fixture()
f.tab(S1).hooks.panel.subscribe(() => {})
f.header(S2)
f.list('row').hooks.states.subscribe(() => {})
expect(f.streams).toHaveLength(1)
expect(f.stateStreams).toHaveLength(1)
await f.dispose()
expect(f.streams[0]?.disposed).toHaveBeenCalled()
expect(f.streams[0]?.signal.aborted).toBe(true)
expect(f.stateStreams[0]?.disposed).toHaveBeenCalled()
expect(f.stateStreams[0]?.signal.aborted).toBe(true)
expect(f.unregisterTab).toHaveBeenCalled()
expect(f.unmount).toHaveBeenCalledOnce()
for (const [slot, component] of [
@@ -275,9 +305,12 @@ describe('mountForgePanel registration', () => {
['conversation.hero.options', WorktreeToggle],
['conversation.input.dock', WorktreeProgress],
['shell.overlay', ActionToast],
['sidebar.session.row.trailing', SessionRowForge],
['sidebar.session.row.hover', SessionHoverForge],
] as const) expect(f.entry(slot, component)).toBeUndefined()
f.ctx.emit('connection/reset')
expect(f.forgePanel.follow).toHaveBeenCalledOnce()
expect(f.forgePanel.changeRequestStates).toHaveBeenCalledOnce()
})
it('unmounts the Remote when the UI registration fails', async () => {
@@ -290,6 +323,28 @@ describe('mountForgePanel registration', () => {
})
})
describe('mountForgePanel Session list', () => {
it('shares one change request state stream among every Session row and hover card while any of them reads it', async () => {
const f = await fixture()
const states = f.list('row').hooks.states
expect(f.list('hover').hooks.states).toBe(states)
expect(f.forgePanel.changeRequestStates).not.toHaveBeenCalled()
const row = states.subscribe(() => {})
const hover = states.subscribe(() => {})
f.stateStreams[0]?.push({ type: 'baseline', states: [listState(1)] })
await vi.waitFor(() => { expect([...states.getSnapshot().state.values()]).toEqual([listState(1)]) })
row()
expect(f.stateStreams[0]?.disposed).not.toHaveBeenCalled()
hover()
expect(f.forgePanel.changeRequestStates).toHaveBeenCalledOnce()
expect(f.stateStreams[0]?.disposed).toHaveBeenCalled()
expect(f.stateStreams[0]?.signal.aborted).toBe(true)
await f.dispose()
})
})
describe('mountForgePanel Session stores', () => {
it('shares one panel stream per Session between the tab, the header button, the worktree option, and the worktree card', async () => {
const f = await fixture()
@@ -0,0 +1,189 @@
// @vitest-environment jsdom
/**
* Session row icons and hover card section: the worktree the Session works
* in, and its visible change requests ordered by urgency.
*/
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { SessionListState } from '@deepseek-ai/dsh-api-session-controller/client'
import { createSnapshotStore } from '@deepseek-ai/dsh-client-store'
import { bindSnapshotSelector, makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import type { BindingState } from '@deepseek-ai/dsh-experimental-forge-session/types'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionProjectionSummaryValues } from '@deepseek-ai/dsh-session-projection/types'
import { en } from '../src/client/locales.ts'
import { sessionForgeFacts, type ChangeRequestStates } from '../src/client/session-list.ts'
import { HOVER_CHANGE_REQUEST_LIMIT, SessionHoverForge, type SessionHoverForgeProps } from '../src/client/SessionHoverForge.tsx'
import { SessionRowForge, stateLine, type SessionRowForgeProps } from '../src/client/SessionRowForge.tsx'
import css from '../src/client/SessionList.module.css'
import type { ForgeChangeRequestListState } from '../src/types.ts'
import { created, listState as state } from './client-fixtures.client.ts'
afterEach(cleanup)
const t = makeTranslate(en)
const S1 = SessionId('s1')
type Values = Readonly<Partial<SessionProjectionSummaryValues>>
function url(number: number): string {
return `https://github.com/octo/demo/pull/${number}`
}
function binding(number: number, dismissed = false): BindingState {
return { url: url(number), origin: 'referenced', dismissed }
}
function statesOf(...values: ForgeChangeRequestListState[]): ReadonlyMap<string, ForgeChangeRequestListState> {
return new Map(values.map(value => [value.url, value]))
}
function listed(values: Values | undefined): SessionListState {
return {
ids: [S1],
byId: { [S1]: { id: S1, displayTitle: 'S1', running: false, blank: false, updatedAt: 0, retainedBy: {}, ...(values === undefined ? {} : { projectionValues: values }) } },
phase: 'ready',
projectionsBySession: {},
}
}
/** Stores behind one row's hooks, which the test replaces to drive renders. */
function stores(values: Values | undefined, states: ReadonlyMap<string, ForgeChangeRequestListState> = new Map()) {
const sessions = createSnapshotStore<SessionListState>(listed(values))
const changeRequests = createSnapshotStore<ChangeRequestStates>({ state: states, disconnected: false })
const props = { sessionId: S1, useSessions: bindSnapshotSelector(sessions), useStates: bindSnapshotSelector(changeRequests), t }
return { sessions, changeRequests, props }
}
function renderRow(values: Values | undefined, states?: ReadonlyMap<string, ForgeChangeRequestListState>) {
const { sessions, changeRequests, props } = stores(values, states)
const view = render(<SessionRowForge {...props as SessionRowForgeProps} />)
return { view, sessions, changeRequests }
}
function renderHover(values: Values | undefined, states?: ReadonlyMap<string, ForgeChangeRequestListState>) {
const { props } = stores(values, states)
return render(<SessionHoverForge {...props as SessionHoverForgeProps} />)
}
describe('sessionForgeFacts', () => {
it('shares one empty value for every Session without a worktree or a visible change request', () => {
const empty = sessionForgeFacts(undefined, new Map())
expect(empty).toEqual({ worktree: null, changeRequests: [] })
expect(sessionForgeFacts({ forgeWorktree: null, forgeChangeRequests: [binding(1, true)] }, statesOf(state(1)))).toBe(empty)
})
it('orders visible change requests by urgency, keeping their binding order among equally urgent ones', () => {
const facts = sessionForgeFacts({
forgeWorktree: created(),
forgeChangeRequests: [1, 2, 3, 4, 5, 6, 7, 8].map(number => binding(number, number === 8)),
}, statesOf(
state(1, { state: 'merged' }),
state(2),
state(4, { checksRollup: 'failure' }),
state(5, { draft: true }),
state(6, { state: 'closed' }),
state(7, { checksRollup: 'pending' }),
state(8),
))
expect(facts.worktree).toEqual(created())
expect(facts.changeRequests.map(entry => [entry.url, entry.tone])).toEqual([
[url(4), 'blocked'], [url(2), 'open'], [url(7), 'open'], [url(5), 'draft'], [url(3), 'unknown'], [url(1), 'merged'], [url(6), 'closed'],
])
expect(facts.changeRequests[4]?.state).toBeUndefined()
expect(facts.changeRequests[0]?.state).toEqual(state(4, { checksRollup: 'failure' }))
})
})
describe('SessionRowForge', () => {
it('shows nothing for a Session without a worktree or change requests', () => {
expect(renderRow(undefined).view.container.innerHTML).toBe('')
})
it('shows the worktree the Session works in and the state of its most urgent change request with its checks', () => {
renderRow({ forgeWorktree: created(), forgeChangeRequests: [binding(1), binding(2)] }, statesOf(state(1, { checksRollup: 'pending' }), state(2)))
expect(screen.getByRole('img', { name: en['list.worktree'] }).getAttribute('data-forge-row-worktree')).toBe('')
const change = screen.getByRole('img', { name: 'Change request state: Open · Checks running' })
expect(change.getAttribute('data-forge-row-change')).toBe('open')
expect(change.querySelector(`.${css.dot}`)?.getAttribute('data-checks')).toBe('pending')
})
it('names only the state of a finished or unpolled change request, without a checks dot', () => {
const merged = renderRow({ forgeChangeRequests: [binding(1)] }, statesOf(state(1, { state: 'merged', checksRollup: 'failure' })))
const glyph = screen.getByRole('img', { name: 'Change request state: Merged' })
expect(glyph.getAttribute('data-forge-row-change')).toBe('merged')
expect(glyph.querySelector(`.${css.dot}`)).toBeNull()
expect(screen.queryByRole('img', { name: en['list.worktree'] })).toBeNull()
merged.view.unmount()
renderRow({ forgeChangeRequests: [binding(2)] })
expect(screen.getByRole('img', { name: 'Change request state: Unknown' }).querySelector(`.${css.dot}`)).toBeNull()
})
it('follows the listed values and the polled states as they change', () => {
const { sessions, changeRequests } = renderRow({ forgeChangeRequests: [binding(1)] })
screen.getByRole('img', { name: 'Change request state: Unknown' })
act(() => { changeRequests.set({ state: statesOf(state(1, { mergeability: 'conflicting' })), disconnected: false }) })
screen.getByRole('img', { name: 'Change request state: Needs attention · Checks passed' })
act(() => { sessions.set(listed({ forgeChangeRequests: [binding(1, true)] })) })
expect(screen.queryByRole('img')).toBeNull()
})
it('joins a change request state and its checks in one line', () => {
const [entry] = sessionForgeFacts({ forgeChangeRequests: [binding(1)] }, statesOf(state(1, { checksRollup: 'none' }))).changeRequests
expect(entry).toBeDefined()
if (entry !== undefined) expect(stateLine(entry, t)).toBe('Open')
})
})
describe('SessionHoverForge', () => {
it('shows nothing for a Session without a worktree or change requests', () => {
expect(renderHover({ forgeWorktree: null, forgeChangeRequests: [] }).container.innerHTML).toBe('')
})
it('names the worktree branch and what it started from, the requested base or else the short base commit', () => {
const from = renderHover({ forgeWorktree: created() })
screen.getByText('dsh/0a1b2c3d')
screen.getByText('Worktree · from main')
from.unmount()
renderHover({ forgeWorktree: created({ from: null, baseCommit: '0123456789abcdef' }) })
screen.getByText('Worktree · from 0123456')
})
it('lists the most urgent change requests with their state and checks, and counts the rest', () => {
const bindings = [1, 2, 3, 4, 5].map(number => binding(number))
const { container } = renderHover({ forgeChangeRequests: bindings }, statesOf(
state(1, { checksRollup: 'failure' }),
state(2, { state: 'merged' }),
state(3, { checksRollup: 'pending', title: 'Wire the list' }),
state(5),
))
const items = [...container.querySelectorAll('[data-forge-session-hover-change]')]
expect(items).toHaveLength(HOVER_CHANGE_REQUEST_LIMIT)
expect(items.map(item => item.getAttribute('data-forge-session-hover-change'))).toEqual(['blocked', 'open', 'open'])
expect(items.map(item => item.querySelector(`.${css.hoverTitle}`)?.textContent)).toEqual(['#1 Change 1', '#3 Wire the list', '#5 Change 5'])
expect(items.map(item => item.querySelector(`.${css.hoverNote} [aria-hidden]`)?.textContent))
.toEqual(['Needs attention·Checks failed', 'Open·Checks running', 'Open·Checks passed'])
expect(items.map(item => item.querySelector(`.${css.visuallyHidden}`)?.textContent))
.toEqual(['Needs attention · Checks failed', 'Open · Checks running', 'Open · Checks passed'])
expect(items[0]?.querySelector('[data-checks]')?.getAttribute('data-checks')).toBe('failure')
screen.getByText('2 more change requests')
})
it('names an unpolled change request by its URL, and a finished one by its state alone', () => {
const { container } = renderHover({ forgeChangeRequests: [binding(1), binding(2)] }, statesOf(state(2, { state: 'closed' })))
const items = [...container.querySelectorAll('[data-forge-session-hover-change]')]
expect(items.map(item => item.querySelector(`.${css.hoverTitle}`)?.textContent)).toEqual([url(1), '#2 Change 2'])
expect(items.map(item => item.querySelector(`.${css.visuallyHidden}`)?.textContent)).toEqual(['Unknown', 'Closed'])
expect(container.querySelector('[data-checks]')).toBeNull()
expect(screen.queryByText(/more change requests/)).toBeNull()
})
})
@@ -1,23 +1,23 @@
import { describe, expect, it, vi } from 'vitest'
import { createNoticeSource, createPanelSource, createVisibilitySource } from '../src/client/source.ts'
import { createNoticeSource, createPanelSource, createStreamSource, createVisibilitySource, foldStates } from '../src/client/source.ts'
import type { PanelStream } from '../src/client/source.ts'
import type { ForgePanelState } from '../src/types.ts'
import { entry, ready, snapshot } from './client-fixtures.client.ts'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame, ForgePanelState } from '../src/types.ts'
import { entry, listState, ready, snapshot } from './client-fixtures.client.ts'
/** A stream the test feeds by hand. */
function feed() {
const queue: ForgePanelState[] = []
function feed<F = ForgePanelState>() {
const queue: F[] = []
let wake: (() => void) | undefined
let closed = false
let failure: unknown
const stream = {
signal: undefined as AbortSignal | undefined,
disposed: vi.fn(),
push(state: ForgePanelState) { queue.push(state); wake?.() },
push(frame: F) { queue.push(frame); wake?.() },
fail(error: unknown) { failure = error; wake?.() },
close() { closed = true; wake?.() },
dispose() { stream.disposed(); closed = true; wake?.() },
async *[Symbol.asyncIterator](): AsyncIterator<ForgePanelState> {
async *[Symbol.asyncIterator](): AsyncIterator<F> {
while (true) {
const next = queue.shift()
if (next !== undefined) { yield next; continue }
@@ -207,6 +207,49 @@ describe('createPanelSource', () => {
})
})
describe('change request states', () => {
it('builds states from a baseline and its changes, and lets the baseline after a transport reset replace them', async () => {
const streams: ReturnType<typeof feed<ForgeChangeRequestStatesFrame>>[] = []
const resets = new Set<() => void>()
const source = createStreamSource<ForgeChangeRequestStatesFrame, ReadonlyMap<string, ForgeChangeRequestListState>>({
open: () => {
const stream = feed<ForgeChangeRequestStatesFrame>()
streams.push(stream)
return stream
},
subscribeReset: (listener) => {
resets.add(listener)
return () => { resets.delete(listener) }
},
}, new Map(), foldStates)
const seen: (readonly ForgeChangeRequestListState[])[] = []
const unsubscribe = source.observable.subscribe(() => { seen.push([...source.observable.getSnapshot().state.values()]) })
streams[0]?.push({ type: 'baseline', states: [listState(1), listState(2)] })
streams[0]?.push({ type: 'changed', states: [listState(2, { state: 'merged' })] })
await vi.waitFor(() => { expect(seen).toHaveLength(2) })
for (const listener of [...resets]) listener()
streams[1]?.push({ type: 'baseline', states: [listState(3)] })
await vi.waitFor(() => { expect(seen).toHaveLength(3) })
unsubscribe()
expect(seen).toEqual([
[listState(1), listState(2)],
[listState(1), listState(2, { state: 'merged' })],
[listState(3)],
])
})
it('folds a change into new states and leaves the earlier ones unchanged', () => {
const before = foldStates(new Map(), { type: 'baseline', states: [listState(1)] })
const after = foldStates(before, { type: 'changed', states: [listState(1, { draft: true })] })
expect([...before.values()]).toEqual([listState(1)])
expect([...after.values()]).toEqual([listState(1, { draft: true })])
})
})
describe('createNoticeSource', () => {
it('publishes numbered failure notices without values or undo by default, and their dismissal', () => {
const notices = createNoticeSource()
@@ -0,0 +1,182 @@
/** `forgePanel.changeRequestStates` over the Host's Session list, projection cache, and storage. */
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { Context } from '@deepseek-ai/cordis'
import type { SessionHeader } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { storedKey, type StoredStates } from '../src/states.ts'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame } from '../src/types.ts'
import { bench, pullUrl, type Bench } from './harness.ts'
const cleanups: (() => Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
/** The listed state of the fake provider's snapshot of `number`. */
function state(number: number): ForgeChangeRequestListState {
return {
url: pullUrl(number),
number,
title: `Change ${number}`,
state: 'open',
draft: false,
checksRollup: 'success',
reviewDecision: 'none',
mergeability: 'mergeable',
}
}
/** A panel Remote with one loaded Session, the Sessions the Host lists, their cached summaries, and stored states. */
async function composed(options: { readonly remoteAccess?: (id: SessionId) => boolean } = {}) {
const cwd = await mkdtemp(join(tmpdir(), 'forge-states-'))
cleanups.push(() => rm(cwd, { recursive: true, force: true }))
const b = await bench(cwd)
cleanups.push(() => b.ctx.fiber.dispose())
const cold: SessionHeader = { ...b.session.header, id: SessionId('cold') }
const unseen: SessionHeader = { ...b.session.header, id: SessionId('unseen') }
const records = new Map<string, ForgeChangeRequestListState>()
const put = vi.fn<StoredStates['put']>(async (key, value) => { records.set(key, value) })
const remove = vi.fn<StoredStates['delete']>(async key => records.delete(key))
const table: StoredStates = { get: key => records.get(key), entries: () => records.entries(), put, delete: remove }
provide(b.ctx, 'sessionQuery', { listSessions: async () => [{ header: b.session.header }, { header: cold }, { header: unseen }] })
provide(b.ctx, 'sessionProjectionCache', {
cachedSnapshot: (header: SessionHeader) => header.id === cold.id
? {
asOfSeq: 3,
values: {
forgeChangeRequests: [
{ url: pullUrl(8), origin: 'tracked', dismissed: false },
{ url: pullUrl(10), origin: 'tracked', dismissed: true },
],
},
}
: undefined,
})
provide(b.ctx, 'storageDomain', { open: async () => ({ table: () => table }) })
if (options.remoteAccess !== undefined) {
const allows = options.remoteAccess
provide(b.ctx, 'remoteAccess', { allows: ({ id }: { readonly id: SessionId }) => allows(id) })
}
return { b, cold, records, put }
}
/** Register a fake Host service with only the members the panel reads. */
function provide(ctx: Context, name: string, value: object): void {
ctx.provide(name, value)
}
/** Read frames of `changeRequestStates` until one satisfies `predicate`, keeping every frame read. */
function reader(b: Bench) {
const aborter = new AbortController()
const iterator = b.ctx.forgePanel.changeRequestStates(aborter.signal)[Symbol.asyncIterator]()
const frames: ForgeChangeRequestStatesFrame[] = []
cleanups.push(async () => {
aborter.abort()
await iterator.return?.(undefined)
})
return {
frames,
async until(
predicate: (states: ReadonlyMap<string, ForgeChangeRequestListState>) => boolean,
): Promise<ReadonlyMap<string, ForgeChangeRequestListState>> {
const seen = new Map<string, ForgeChangeRequestListState>()
for (const frame of frames) for (const value of frame.states) seen.set(value.url, value)
while (!predicate(seen)) {
const result = await iterator.next()
if (result.done === true) throw new Error('the stream ended')
frames.push(result.value)
for (const value of result.value.states) seen.set(value.url, value)
}
return seen
},
}
}
describe('forgePanel.changeRequestStates', () => {
it('streams the states of the visible bindings of loaded and cached Sessions, then of later bindings', async () => {
const { b, records } = await composed()
b.ctx.forgeSession.track(b.session, pullUrl(7))
const r = reader(b)
const first = await r.until(states => states.has(pullUrl(7)) && states.has(pullUrl(8)))
expect(r.frames[0]).toEqual({ type: 'baseline', states: [] })
expect([...first.values()]).toEqual(expect.arrayContaining([state(7), state(8)]))
expect(first.has(pullUrl(10))).toBe(false)
// Other client-visible units of a loaded Session change without rebinding anything.
b.session.append('working-directory/change', { cwd: `${b.session.header.cwd ?? ''}/sub` })
b.ctx.forgeSession.track(b.session, pullUrl(9))
expect((await r.until(states => states.has(pullUrl(9)))).get(pullUrl(9))).toEqual(state(9))
expect(records.get(storedKey(pullUrl(9)))).toEqual(state(9))
// A second stream shares the run and starts from the states the first one saw.
expect(await reader(b).until(states => states.has(pullUrl(9)))).toEqual(await r.until(() => true))
expect(b.provider.fetches.flat().filter(number => number === 9)).toHaveLength(1)
})
it('sends only the states of change requests that a Session the caller may read binds', async () => {
const { b, cold } = await composed({ remoteAccess: id => id === cold.id })
b.ctx.forgeSession.track(b.session, pullUrl(7))
const r = reader(b)
const states = await r.until(seen => seen.has(pullUrl(8)))
b.ctx.forgeSession.track(b.session, pullUrl(9))
await vi.waitFor(() => { expect(b.provider.fetches.flat()).toEqual(expect.arrayContaining([7, 8, 9])) })
expect([...states.keys()]).toEqual([pullUrl(8)])
})
it('warns when storing a state fails and still sends it', async () => {
const { b, put } = await composed()
const warn = vi.spyOn(b.ctx.logger, 'warn').mockImplementation(() => undefined)
put.mockRejectedValue(new Error('disk full'))
b.ctx.forgeSession.track(b.session, pullUrl(7))
expect((await reader(b).until(states => states.has(pullUrl(7)))).get(pullUrl(7))).toEqual(state(7))
await vi.waitFor(() => { expect(warn).toHaveBeenCalledWith('forge-panel: storing a change request state failed: Error: disk full') })
})
it('refuses to stream without the Session list and storage services', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'forge-states-'))
cleanups.push(() => rm(cwd, { recursive: true, force: true }))
const b = await bench(cwd)
cleanups.push(() => b.ctx.fiber.dispose())
await expect(reader(b).until(() => false)).rejects.toThrow(
'forge-panel: change request states need the sessionQuery, sessionProjectionCache, storageDomain, and sessions services',
)
})
it('replaces the closed cache with a reopened provider result', async () => {
const { b, records } = await composed()
records.set(storedKey(pullUrl(7)), { ...state(7), state: 'closed' })
b.ctx.forgeSession.track(b.session, pullUrl(7))
const r = reader(b)
await r.until(states => states.get(pullUrl(7))?.state === 'open')
expect(r.frames[0]?.states).toContainEqual({ ...state(7), state: 'closed' })
expect(records.get(storedKey(pullUrl(7)))?.state).toBe('open')
})
it('finishes accepted state storage before the controller owner unloads', async () => {
const { b, put, records } = await composed()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const order: string[] = []
put.mockImplementationOnce(async (key, value) => {
entered.resolve(undefined)
await release.promise
records.set(key, value)
order.push('written')
})
b.ctx.forgeSession.track(b.session, pullUrl(7))
const reading = reader(b).until(states => states.has(pullUrl(7)))
await entered.promise
const disposing = b.ctx.fiber.dispose().then(() => { order.push('disposed') })
release.resolve(undefined)
await Promise.all([reading, disposing])
expect(order).toEqual(['written', 'disposed'])
expect(records.has(storedKey(pullUrl(7)))).toBe(true)
})
})
@@ -0,0 +1,370 @@
/** The Host's last known state of every change request that a listed Session binds. */
import { afterEach, describe, expect, it, vi } from 'vitest'
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
import type { ChangeRequestListener, ChangeRequestRef, ChangeRequestSnapshot } from '@deepseek-ai/dsh-experimental-forge/types'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { ChangeRequestStates, storedKey, type ChangeRequestStateSources, type ListedSession, type StoredStates } from '../src/states.ts'
import type { ForgeChangeRequestListState, ForgeChangeRequestStatesFrame } from '../src/types.ts'
import { demo, pullUrl, snapshot as openSnapshot } from './harness.ts'
const S1 = SessionId('s1')
const S2 = SessionId('s2')
const cleanups: (() => void | Promise<void>)[] = []
afterEach(async () => {
for (const cleanup of cleanups.splice(0).reverse()) await cleanup()
})
const url = pullUrl
function ref(number: number): ChangeRequestRef {
return { repository: demo, number }
}
function snapshot(number: number, overrides: Partial<ChangeRequestSnapshot>): ChangeRequestSnapshot {
return { ...openSnapshot(ref(number)), ...overrides }
}
/** The listed state of `snapshot(number)` with its overrides. */
function state(number: number, overrides: Partial<ForgeChangeRequestListState> = {}): ForgeChangeRequestListState {
return {
url: url(number),
number,
title: `Change ${number}`,
state: 'open',
draft: false,
checksRollup: 'success',
reviewDecision: 'none',
mergeability: 'mergeable',
...overrides,
}
}
/** Sources over fixed listed Sessions, stored states, and polls the test drives. */
function fixture(sessions: readonly ListedSession[], stored: readonly ForgeChangeRequestListState[] = []) {
const watchers = new Map<number, ChangeRequestListener>()
const listeners = new Set<(id: SessionId, urls: readonly string[]) => void>()
const records = new Map(stored.map(value => [storedKey(value.url), value]))
const put = vi.fn<StoredStates['put']>(async (key, value) => { records.set(key, value) })
const remove = vi.fn<StoredStates['delete']>(async key => records.delete(key))
const table: StoredStates = { get: key => records.get(key), entries: () => records.entries(), put, delete: remove }
const sources = {
sessions: vi.fn<ChangeRequestStateSources['sessions']>(async () => sessions),
onBindings: vi.fn<ChangeRequestStateSources['onBindings']>((listener) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
}),
refOf: (value: string): ChangeRequestRef | undefined => {
const match = /^https:\/\/fake\.test\/octo\/demo\/pull\/(\d+)$/.exec(value)
return match === null ? undefined : ref(Number(match[1]))
},
watch: vi.fn<ChangeRequestStateSources['watch']>((target, listener) => {
watchers.set(target.number, listener)
return () => { watchers.delete(target.number) }
}),
open: vi.fn<ChangeRequestStateSources['open']>(async () => table),
report: vi.fn<ChangeRequestStateSources['report']>(),
}
const hub = new ChangeRequestStates(sources)
cleanups.push(() => hub.dispose())
return {
hub,
sources,
records,
put,
remove,
watchers,
listeners,
poll(number: number, overrides: Partial<ChangeRequestSnapshot> = {}): void {
watchers.get(number)?.({ key: changeRequestKey(ref(number)), fetchedAt: 1, snapshot: snapshot(number, overrides) })
},
fail(number: number): void {
watchers.get(number)?.({ key: changeRequestKey(ref(number)), fetchedAt: 1, failure: { code: 'unavailable', message: 'down' } })
},
bind(id: SessionId, urls: readonly string[]): void {
for (const listener of listeners) listener(id, urls)
},
}
}
/** One stream of the hub, read frame by frame. */
function reader(hub: ChangeRequestStates, allowed: (id: SessionId) => boolean = () => true) {
const aborter = new AbortController()
const iterator = hub.stream(allowed, aborter.signal)[Symbol.asyncIterator]()
cleanups.push(() => { aborter.abort() })
return {
async next(): Promise<ForgeChangeRequestStatesFrame | undefined> {
const result = await iterator.next()
return result.done === true ? undefined : result.value
},
async close(): Promise<void> {
aborter.abort()
await iterator.return?.(undefined)
},
}
}
describe('ChangeRequestStates', () => {
it('streams the stored state of every change request a listed Session binds, then the states its polls change', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }, { id: S2, urls: undefined }], [state(7, { checksRollup: 'pending' })])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [state(7, { checksRollup: 'pending' })] })
f.poll(7)
expect(await r.next()).toEqual({ type: 'changed', states: [state(7)] })
expect(f.records.get(storedKey(url(7)))).toEqual(state(7))
})
it('shares one watch per request and retains it for explicit refresh after merging or closing', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }, { id: S2, urls: [url(7), url(8)] }])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [] })
expect(f.sources.watch.mock.calls.map(([target]) => target.number)).toEqual([7, 8])
f.poll(7, { state: 'merged', closedAt: '2026-10-20T00:00:00Z' })
expect(await r.next()).toEqual({ type: 'changed', states: [state(7, { state: 'merged' })] })
f.poll(8, { state: 'closed', closedAt: '2026-10-20T00:00:00Z' })
expect(await r.next()).toEqual({ type: 'changed', states: [state(8, { state: 'closed' })] })
expect([...f.watchers.keys()]).toEqual([7, 8])
})
it('watches stored merged and closed requests for fresh results', async () => {
const f = fixture([{ id: S1, urls: [url(7), url(8)] }], [state(7, { state: 'merged' }), state(8, { state: 'closed' })])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [state(7, { state: 'merged' }), state(8, { state: 'closed' })] })
expect(f.sources.watch.mock.calls.map(([target]) => target.number)).toEqual([7, 8])
})
it('starts polling a change request when a Session binds it and stops once no Session binds it', async () => {
const f = fixture([{ id: S1, urls: [] }])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [] })
f.bind(S1, [url(9)])
expect([...f.watchers.keys()]).toEqual([9])
f.poll(9, { draft: true })
expect(await r.next()).toEqual({ type: 'changed', states: [state(9, { draft: true })] })
f.bind(S1, [])
expect([...f.watchers.keys()]).toEqual([])
})
it('sends a stream only the states of change requests that a Session it may read binds, including ones bound later', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }, { id: S2, urls: [url(8)] }], [state(7), state(8)])
const r = reader(f.hub, id => id === S1)
expect(await r.next()).toEqual({ type: 'baseline', states: [state(7)] })
f.poll(8, { checksRollup: 'failure' })
f.poll(7, { checksRollup: 'pending' })
expect(await r.next()).toEqual({ type: 'changed', states: [state(7, { checksRollup: 'pending' })] })
f.bind(S1, [url(7), url(8)])
expect(await r.next()).toEqual({ type: 'changed', states: [state(8, { checksRollup: 'failure' })] })
})
it('stores and sends nothing for failed polls and polls that change nothing', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }], [state(7)])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [state(7)] })
f.poll(7)
f.fail(7)
f.poll(7, { title: 'Renamed' })
expect(await r.next()).toEqual({ type: 'changed', states: [state(7, { title: 'Renamed' })] })
expect(f.put).toHaveBeenCalledOnce()
})
it('stops polling and listening once its last stream ends, and starts again for the next stream', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
const first = reader(f.hub)
const second = reader(f.hub)
await first.next()
await second.next()
await first.close()
expect([...f.watchers.keys()]).toEqual([7])
await second.close()
expect([...f.watchers.keys()]).toEqual([])
expect(f.listeners.size).toBe(0)
const third = reader(f.hub)
expect(await third.next()).toEqual({ type: 'baseline', states: [] })
expect(f.sources.sessions).toHaveBeenCalledTimes(2)
expect([...f.watchers.keys()]).toEqual([7])
})
it('forgets stored states that no listed Session binds', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }], [state(7), state(9)])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [state(7)] })
expect(f.remove).toHaveBeenCalledExactlyOnceWith(storedKey(url(9)))
expect([...f.records.values()]).toEqual([state(7)])
})
it('reports a failed write and still sends the polled state', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
const failure = new Error('disk full')
f.put.mockRejectedValueOnce(failure)
const r = reader(f.hub)
await r.next()
f.poll(7)
expect(await r.next()).toEqual({ type: 'changed', states: [state(7)] })
await vi.waitFor(() => { expect(f.sources.report).toHaveBeenCalledWith(failure) })
})
it('polls nothing for a URL no provider recognizes', async () => {
const f = fixture([{ id: S1, urls: ['https://gitlab.example/group/app/-/merge_requests/3'] }])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [] })
expect(f.sources.watch).not.toHaveBeenCalled()
})
it('reports a failed removal of a stored state that no listed Session binds', async () => {
const f = fixture([{ id: S1, urls: [] }], [state(9)])
const failure = new Error('read-only')
f.remove.mockRejectedValueOnce(failure)
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [] })
await vi.waitFor(() => { expect(f.sources.report).toHaveBeenCalledWith(failure) })
})
it('fails the streams of a run that cannot start, and starts a new run for the next stream', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
f.sources.open.mockRejectedValueOnce(new Error('storage unavailable'))
await expect(reader(f.hub).next()).rejects.toThrow('storage unavailable')
expect(await reader(f.hub).next()).toEqual({ type: 'baseline', states: [] })
expect([...f.watchers.keys()]).toEqual([7])
})
it('ends a stream whose run the hub stops while it starts, leaving nothing followed or polled', async () => {
const f = fixture([])
let list: (sessions: readonly ListedSession[]) => void = () => undefined
f.sources.sessions.mockReturnValueOnce(new Promise((resolve) => { list = resolve }))
const r = reader(f.hub)
const first = r.next()
await vi.waitFor(() => { expect(f.sources.sessions).toHaveBeenCalledOnce() })
const disposing = f.hub.dispose()
list([{ id: S1, urls: [url(7)] }])
expect(await first).toBeUndefined()
await disposing
expect(f.listeners.size).toBe(0)
expect([...f.watchers.keys()]).toEqual([])
})
it('ends every stream and stops polling when disposed', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
const r = reader(f.hub)
await r.next()
await f.hub.dispose()
expect(await r.next()).toBeUndefined()
expect([...f.watchers.keys()]).toEqual([])
expect(f.listeners.size).toBe(0)
})
it('ends a stream cancelled after its baseline and releases its watches', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
const aborter = new AbortController()
const stream = f.hub.stream(() => true, aborter.signal)[Symbol.asyncIterator]()
await stream.next()
aborter.abort()
try {
expect((await stream.next()).done).toBe(true)
expect([...f.watchers.keys()]).toEqual([])
} finally {
await f.hub.dispose()
await stream.return?.(undefined)
}
})
it('drains an accepted write before disposal and refuses streams after disposal', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
const order: string[] = []
f.put.mockImplementationOnce(async (key, value) => {
entered.resolve(undefined)
await release.promise
f.records.set(key, value)
order.push('written')
})
const r = reader(f.hub)
await r.next()
f.poll(7)
await entered.promise
const disposing = f.hub.dispose().then(() => { order.push('disposed') })
release.resolve(undefined)
await disposing
expect(order).toEqual(['written', 'disposed'])
expect(f.records.get(storedKey(url(7)))).toEqual(state(7))
expect(await reader(f.hub).next()).toBeUndefined()
expect(f.sources.open).toHaveBeenCalledTimes(1)
})
it('replaces a cached closed state when its shared watch observes reopening', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }], [state(7, { state: 'closed' })])
const r = reader(f.hub)
expect(await r.next()).toEqual({ type: 'baseline', states: [state(7, { state: 'closed' })] })
f.poll(7, { state: 'open', checksRollup: 'pending' })
expect(await r.next()).toEqual({ type: 'changed', states: [state(7, { checksRollup: 'pending' })] })
expect(f.records.get(storedKey(url(7)))?.state).toBe('open')
})
it('keeps binding updates during listing ahead of the initial snapshot', async () => {
const f = fixture([{ id: S1, urls: [url(8)] }])
const captured = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
f.sources.sessions.mockImplementationOnce(async () => {
captured.resolve(undefined)
await release.promise
return [{ id: S1, urls: [url(8)] }]
})
const r = reader(f.hub)
const first = r.next()
await captured.promise
f.bind(S1, [url(7)])
f.bind(S2, [url(9)])
release.resolve(undefined)
await first
expect([...f.watchers.keys()]).toEqual([7, 9])
})
it('retains stored states while an uncached Session may still bind them', async () => {
const f = fixture([{ id: S1, urls: undefined }], [state(7)])
await reader(f.hub).next()
expect(f.remove).not.toHaveBeenCalled()
f.bind(S1, [url(7)])
expect([...f.watchers.keys()]).toEqual([7])
})
it('releases the binding listener after listing fails', async () => {
const f = fixture([])
f.sources.sessions.mockRejectedValueOnce(new Error('list unavailable'))
await expect(reader(f.hub).next()).rejects.toThrow('list unavailable')
expect(f.listeners.size).toBe(0)
expect(await reader(f.hub).next()).toEqual({ type: 'baseline', states: [] })
})
it('keeps other readers live when a reader aborts between frames', async () => {
const f = fixture([{ id: S1, urls: [url(7)] }])
const aborter = new AbortController()
const first = f.hub.stream(() => true, aborter.signal)[Symbol.asyncIterator]()
const second = reader(f.hub)
await first.next()
await second.next()
aborter.abort()
f.poll(7)
expect((await first.next()).done).toBe(true)
expect(await second.next()).toEqual({ type: 'changed', states: [state(7)] })
expect([...f.watchers.keys()]).toEqual([7])
await second.close()
expect([...f.watchers.keys()]).toEqual([])
})
})
@@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-working-directory'
import type { ForgeSessionWorktree, ForgeWorktreeCreation, ForgeWorktreeState } from '../src/index.ts'
import { CreationFailure, forgeWorktreeProjection, WORKTREE_RECORD, WorktreeCreations } from '../src/worktree.ts'
import {
branches, commitAt, git, hold, notice, prompt, proposeStep, rejectionOf, repository, runtime, scratch, startStep, startTurn,
branches, commitAt, git, hold, notice, plainSession, prompt, proposeStep, rejectionOf, repository, runtime, scratch, startStep, startTurn,
} from './harness.ts'
import type { Runtime } from './harness.ts'
@@ -28,21 +28,53 @@ const WORKTREE: ForgeSessionWorktree = {
path: '/work/repo/.agents/worktrees/dsh/0a1b2c3d', branch: 'dsh/0a1b2c3d', from: null, baseCommit: 'c'.repeat(40), repositoryRoot: '/work/repo',
}
/** The state before any worktree record, turn, or step. */
const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false, restoring: false }
/** The state before any worktree record, turn, step, or directory change of a Session without a recorded directory. */
const EMPTY: ForgeWorktreeState = { request: null, created: null, locked: false, stepped: false, cwd: null, restoring: false }
/** A live Session whose `forgeWorktree` state a registry folds without the panel Remote. */
async function folded(): Promise<{ session: Session; state: () => ForgeWorktreeState }> {
async function folded(): Promise<{ ctx: Context; session: Session; state: () => ForgeWorktreeState }> {
const ctx = new Context()
cleanups.push(() => ctx.fiber.dispose())
await ctx.plugin(SessionStore)
await ctx.plugin(SessionProjectionRegistry)
const states = await ctx.sessionProjections.register(forgeWorktreeProjection)
const session = ctx.sessions.create(SessionId('forge-worktree-projection'))
return { session, state: () => states.stateOf(session) }
return { ctx, session, state: () => states.stateOf(session) }
}
describe('forgeWorktree projection', () => {
it('shows clients the created worktree in the Session summary while the Session works in it', async () => {
const { ctx, session, state } = await folded()
const published: unknown[] = []
ctx.sessionProjections.onChanged((_session, key, value) => { if (key === 'forgeWorktree') published.push(value) })
appendPluginRecord(session, WORKTREE_RECORD, { action: 'request', from: 'main' })
session.append('working-directory/change', { cwd: WORKTREE.path })
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE })
startTurn(session, 1)
startStep(session, 1)
session.append('working-directory/change', { cwd: join(WORKTREE.path, 'packages') })
const inside = [...published]
session.append('working-directory/change', { cwd: WORKTREE.repositoryRoot })
// Shares the worktree's path as a string prefix without lying below it.
session.append('working-directory/change', { cwd: `${WORKTREE.path}-sibling` })
const outside = [...published]
session.append('working-directory/change', { cwd: WORKTREE.path })
const returned = state()
session.append('working-directory/change', { cwd: WORKTREE.path })
expect(state()).toBe(returned)
expect(inside.at(-1)).toEqual(WORKTREE)
expect(inside.filter(value => value !== null)).toEqual([WORKTREE])
expect(outside.slice(inside.length)).toEqual([null])
expect(published.slice(outside.length)).toEqual([WORKTREE])
expect(ctx.sessionProjections.summaryKeys().has('forgeWorktree')).toBe(true)
})
it('starts from the directory the Session header records', () => {
expect(forgeWorktreeProjection.init(plainSession('/work/repo').header)).toEqual({ ...EMPTY, cwd: '/work/repo' })
})
it('replays incomplete restoration until an explicit successful completion', async () => {
const { session, state } = await folded()
appendPluginRecord(session, WORKTREE_RECORD, { action: 'restoring' })
@@ -112,8 +144,8 @@ describe('forgeWorktree projection', () => {
const created = state()
startStep(session, 1)
expect(created).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: false, restoring: false })
expect(state()).toEqual({ request: { from: null }, created: WORKTREE, locked: true, stepped: true, restoring: false })
expect(created).toEqual({ ...EMPTY, request: { from: null }, created: WORKTREE, locked: true })
expect(state()).toEqual({ ...EMPTY, request: { from: null }, created: WORKTREE, locked: true, stepped: true })
})
it('ignores a created worktree without an open request or after the first step starts', async () => {
@@ -134,7 +166,7 @@ describe('forgeWorktree projection', () => {
const stepped = state()
appendPluginRecord(session, WORKTREE_RECORD, { action: 'created', worktree: WORKTREE })
expect(stepped).toEqual({ request: { from: null }, created: null, locked: true, stepped: true, restoring: false })
expect(stepped).toEqual({ ...EMPTY, request: { from: null }, locked: true, stepped: true })
expect(state()).toBe(stepped)
})
@@ -244,6 +276,7 @@ describe('creating a requested worktree', () => {
expect(worktreeOf(opened)).toEqual(created)
expect(opened.records).toEqual([{ action: 'request', from: null }, { action: 'created', worktree: created }])
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(path)
expect(opened.ctx.sessionProjections.snapshot(opened.session, ['forgeWorktree']).values).toEqual({ forgeWorktree: created })
expect(await git(path, 'symbolic-ref', '--short', 'HEAD')).toBe(branch)
expect(await git(path, 'rev-parse', 'HEAD')).toBe(head)
expect(opened.agent.inbox.nextStep).toEqual([notice(path)])
@@ -313,7 +346,7 @@ describe('creating a requested worktree', () => {
const running = { phase: 'prepare', branch, from: 'later', held: false }
expect(opened.changes).toEqual([running, running, null])
expect(opened.records).toEqual([{ action: 'request', from: 'later' }])
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: 'later' } })
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: 'later' }, cwd: repo })
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
})
@@ -469,7 +502,7 @@ describe('worktree creation at the first step proposal', () => {
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')] }))
.rejects.toMatchObject({ name: 'CreationFailure', branch: GENERATED_BRANCH, phase: 'prepare', reason: MISSING_BASE })
const failed = opened.changes[0]?.branch
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: 'later' }, locked: true })
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: 'later' }, locked: true, cwd: repo })
expect(opened.records).toEqual([{ action: 'request', from: 'later' }])
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
@@ -500,7 +533,7 @@ describe('worktree creation at the first step proposal', () => {
expect(await settled).toBe(cause)
expect(opened.changes.at(-1)).toBeNull()
expect(opened.records).toEqual([{ action: 'request', from: null }])
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: null } })
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: null }, cwd: repo })
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
expect(await branches(repo)).toEqual(['main'])
})
@@ -512,7 +545,7 @@ describe('worktree creation at the first step proposal', () => {
await expect(proposeStep(opened.ctx, opened.agent, { turn: 1, step: 1, messages: [prompt('go')] }))
.rejects.toThrow('No worktree service is mounted')
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: null } })
expect(stateOf(opened)).toEqual({ ...EMPTY, request: { from: null }, cwd: repo })
expect(opened.changes).toEqual([])
expect(opened.ctx.workingDirectory.get(opened.session)).toBe(repo)
})
@@ -13,6 +13,7 @@
"src/panel.ts",
"src/parse.ts",
"src/patch-cache.ts",
"src/states.ts",
"src/types.ts",
"src/worktree.ts"
],
@@ -23,6 +24,9 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../api/remote-access/tsconfig.host.json"
},
{
"path": "../../core/agent"
},
@@ -38,12 +42,21 @@
{
"path": "../../sandbox/sandbox-policy"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session/session-projection"
},
{
"path": "../../session/session-projection-cache"
},
{
"path": "../../session/working-directory"
},
{
"path": "../../storage/storage-domain"
},
{
"path": "../../subprocess/subprocess"
},
@@ -9,8 +9,8 @@
en: b8c7e5c40e3c21a4
zh: 2360132d865b9d08
/deepseek-ai-dsh-experimental-forge-session/summary:
en: bd32d5fdeb3d26b1
zh: 69398f65b123305d
en: 6c46b2a70c2f4760
zh: 9425687e258412b4
/deepseek-ai-dsh-experimental-forge-session/table-of-contents:
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
@@ -18,14 +18,14 @@
en: d9de38d0cc22c1bd
zh: 6116653176a9fea7
/deepseek-ai-dsh-experimental-forge-session/understand-the-implementation:
en: ccc7ad69f5837528
zh: 369c08bf4f7e6e4d
en: 46d6625f613e9c69
zh: 688ad6eb25570bc3
/deepseek-ai-dsh-experimental-forge-session/further-exploration:
en: afd5ea387dc53920
zh: 1f2d48bdd1abeef7
/deepseek-ai-dsh-experimental-forge-session/model-experience:
en: 04635389a588fa80
zh: 29654fdca5b9f066
en: c2866463fc210ca0
zh: 0e66693bfd75264b
/deepseek-ai-dsh-experimental-forge-session/model-experience/kv-cache-effect:
en: 1bdaeb7d102c23d8
zh: 37a3f0bd58f68363
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
List the pull requests a Session is working on without a separate registry. A host-only projection derives bindings from the Session's own `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` shell results and from binding records. After each push, and when a caller such as the forge panel reports the branch a Session works on, the service looks up open change requests for that branch and records the unbound ones. Watching a Session polls its bound change requests through `ctx.forge`.
List the pull requests a Session is working on without a separate registry. A projection derives bindings from the Session's own `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` shell results and from binding records. After each push, and when a caller such as the forge panel reports the branch a Session works on, the service looks up open change requests for that branch and records the unbound ones. Watching a Session polls its bound change requests through `ctx.forge`. The Session summary carries the bindings to clients.
## Table of Contents
@@ -50,7 +50,7 @@ Binding actions are `plugin:forge-session/binding` records listed in the [experi
<details>
<summary>Implementation internals — click to expand</summary>
The `forgeChangeRequests` projection has no client view. It keeps shell calls awaiting their first result, every binding with its dismissal state, and each pushed branch with the sequence number of the latest result that reported it. Pending calls are dropped at `turn/end`. The service reacts to a `tool/result` event by discovering exactly the pushes whose sequence number equals the event's, and refreshes watchers when the bindings array changes. Disposing the service aborts lookups and drops their results.
The `forgeChangeRequests` projection keeps shell calls awaiting their first result, every binding with its dismissal state, and each pushed branch with the sequence number of the latest result that reported it. Pending calls are dropped at `turn/end`. Its client view, which the Session summary carries, is every binding with its URL, origin, and dismissal state in first-recorded order, and it keeps its reference while only pending calls or pushes change. The service reacts to a `tool/result` event by discovering exactly the pushes whose sequence number equals the event's, and refreshes watchers when the bindings array changes. Disposing the service aborts lookups and drops their results.
| Source | Responsibility |
|---|---|
@@ -76,7 +76,7 @@ The `forgeChangeRequests` projection has no client view. It keeps shell calls aw
<a id="model-experience"></a>
## Model Experience
None, as bindings, discovery records, and polled status stay on the host; binding records never enter model requests.
None, as bindings, discovery records, and polled status never enter model requests; clients see the bindings through the Session summary.
#### KV Cache effect
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
无需单独的登记簿,即可列出一个 Session 正在处理的 pull request。一个仅宿主侧的投影从 Session 自己的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` shell 结果以及绑定记录派生绑定。每次推送后,以及 forge 面板等调用方报告 Session 所在的分支时,服务为该分支查找打开状态的变更请求(change request,即 pull request 或 merge request),并记录尚未绑定的那些。监听一个 Session 时,会通过 `ctx.forge` 轮询其已绑定的变更请求。
无需单独的登记簿,即可列出一个 Session 正在处理的 pull request。一个投影从 Session 自己的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` shell 结果以及绑定记录派生绑定。每次推送后,以及 forge 面板等调用方报告 Session 所在的分支时,服务为该分支查找打开状态的变更请求(change request,即 pull request 或 merge request),并记录尚未绑定的那些。监听一个 Session 时,会通过 `ctx.forge` 轮询其已绑定的变更请求。Session 摘要把绑定带给客户端。
## 目录
@@ -50,7 +50,7 @@ kind: "package-reference"
<details>
<summary>实现细节——点击展开</summary>
`forgeChangeRequests` 投影没有客户端视图。它保存等待第一个结果的 shell 调用、每个绑定及其隐藏状态,以及每个被推送分支和最近一次报告它的结果的序号。`turn/end` 会丢弃仍在等待的调用。服务收到 `tool/result` 事件时,只对序号等于该事件序号的推送执行发现,并在绑定数组变化时刷新监听方。销毁服务会中止查找并丢弃其结果。
`forgeChangeRequests` 投影保存等待第一个结果的 shell 调用、每个绑定及其隐藏状态,以及每个被推送分支和最近一次报告它的结果的序号。`turn/end` 会丢弃仍在等待的调用。它的客户端视图随 Session 摘要下发,按首次记录的顺序列出每个绑定的 URL、来源与隐藏状态;只有等待中的调用或推送变化时,视图保持同一引用。服务收到 `tool/result` 事件时,只对序号等于该事件序号的推送执行发现,并在绑定数组变化时刷新监听方。销毁服务会中止查找并丢弃其结果。
| 源码 | 职责 |
|---|---|
@@ -76,7 +76,7 @@ kind: "package-reference"
<a id="model-experience"></a>
## 模型体验
无:绑定、发现记录与轮询到的状态都留在宿主侧;绑定记录不会进入模型请求。
无:绑定、发现记录与轮询到的状态都不会进入模型请求;客户端通过 Session 摘要看到绑定。
#### KV Cache 影响
@@ -1,4 +1,4 @@
/** Host-only projection that folds shell results and binding records into one Session's change request bindings. */
/** Projection that folds shell results and binding records into one Session's change request bindings, and shows clients the bindings. */
import { z } from 'zod'
import { pluginRecordOf, SessionSeq } from '@deepseek-ai/dsh-session'
@@ -26,6 +26,12 @@ const bindingRecordSchema: z.ZodType<BindingRecord> = z.object({
url: z.string().regex(/^https?:\/\/\S+$/),
})
const bindingsSchema: z.ZodType<readonly BindingState[]> = z.array(z.object({
url: z.string(),
origin: z.enum(['created', 'referenced', 'discovered', 'tracked']),
dismissed: z.boolean(),
}).strict())
const stateSchema: z.ZodType<ForgeChangeRequestsState> = z.object({
pending: z.array(z.object({
callId: z.string(),
@@ -33,11 +39,7 @@ const stateSchema: z.ZodType<ForgeChangeRequestsState> = z.object({
pushes: z.boolean(),
named: z.array(z.string()),
}).strict()),
bindings: z.array(z.object({
url: z.string(),
origin: z.enum(['created', 'referenced', 'discovered', 'tracked']),
dismissed: z.boolean(),
}).strict()),
bindings: bindingsSchema,
pushes: z.array(z.object({
remote: z.string(),
branch: z.string(),
@@ -55,6 +57,8 @@ const stateSchema: z.ZodType<ForgeChangeRequestsState> = z.object({
* pushed. Failed tool executions bind nothing; a nonzero exit status does not
* matter, because the URL or ref line is the evidence. `turn/end` drops calls
* that never received a result. Binding records bind, restore, and hide URLs.
* Clients, including the Session summary, see the bindings; the array keeps
* its reference while only pending calls and pushes change.
*/
export const forgeChangeRequestsProjection = {
key: 'forgeChangeRequests',
@@ -62,6 +66,7 @@ export const forgeChangeRequestsProjection = {
stateSchema,
init: () => EMPTY,
apply,
wire: { viewSchema: bindingsSchema, view: state => state.bindings },
} satisfies ProjectionDefinition<'forgeChangeRequests', ForgeChangeRequestsState>
function apply(state: ForgeChangeRequestsState, event: SessionEvent): ForgeChangeRequestsState {
@@ -99,3 +99,10 @@ declare module '@deepseek-ai/dsh-session/types' {
'plugin:forge-session/binding': BindingRecord
}
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Every change request bound to the Session, hidden ones included, in binding order. */
forgeChangeRequests: readonly BindingState[]
}
}
@@ -19,6 +19,27 @@ function created(number: number): string {
}
describe('forgeChangeRequests projection', () => {
it('shows clients every binding, hidden ones included, and keeps that view while only pending calls and pushes change', () => {
const session = Session.create(SessionId('client-view'))
const { view, viewSchema } = forgeChangeRequestsProjection.wire
shellTurn(session, [{ callId: 'create', command: 'gh pr create --fill', output: created(1) }])
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'track', url: pullUrl(2) })
appendPluginRecord(session, 'plugin:forge-session/binding', { action: 'dismiss', url: pullUrl(2) })
const bound = fold(session)
const seen = session.snapshotEvents().length
shellTurn(session, [{ callId: 'push', command: 'git push -u origin feat', output: pushOutput('feat') }])
// Folding only the new events on the earlier state mirrors the live drive, which compares views by reference.
const pushed = session.snapshotEvents().slice(seen).reduce((state, event) => apply(state, event), bound)
expect(view(bound)).toEqual([
{ url: pullUrl(1), origin: 'created', dismissed: false },
{ url: pullUrl(2), origin: 'tracked', dismissed: true },
])
expect(viewSchema.parse(view(bound))).toEqual(view(bound))
expect(pushed.pushes).not.toBe(bound.pushes)
expect(view(pushed)).toBe(view(bound))
})
it('starts empty and keeps the state reference for unrelated events', () => {
const session = Session.create(SessionId('unrelated'))
shellTurn(session, [
+2 -2
View File
@@ -15,8 +15,8 @@
en: d152484eb41ac6b4
zh: 2f8a621b991260a1
/deepseek-ai-dsh-experimental-forge/use-this-package:
en: f716be24e1e3bada
zh: be76688ec6fab236
en: 5f4f14ee9aad5072
zh: 6c8606796df24a3e
/deepseek-ai-dsh-experimental-forge/understand-the-implementation:
en: e193cb547c47069b
zh: faa517522402ea41
+1 -1
View File
@@ -38,7 +38,7 @@ Mount the registry with at least one provider package, such as the GitHub provid
`diff(ref, signal)` reads the change request's unified patch as the host computes it, with `a/` and `b/` path prefixes, and `fileText(ref, commit, path, signal)` reads one file of its repository at a commit. Both delegate to the provider of the reference's forge, throw when none is registered, and resolve to the text or to a `ForgeFailure`, which is `too-large` when the host or the provider refuses an answer over its size limit. `fileUrl(ref, commit, path)` returns the web page of that file at that commit.
`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed and merged change requests are not polled again. `refresh(ref)` polls a watched change request at once, including after a terminal state; if a fetch is already in flight for its forge, another fetch starts immediately after it settles. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`, which must be at least `activeIntervalMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults.
`ctx.forge.watch(ref, listener)` is also an effect. The first watcher of a change request polls it at once; `latest(ref)` returns the most recent result. Each forge has at most one fetch in flight, and that fetch includes every due change request of the forge. An open change request is polled every `activeIntervalMs` while checks are pending or mergeability is unknown, and every `idleIntervalMs` otherwise. Closed change requests use `idleIntervalMs` to detect reopening; merged requests stop automatic polling. `refresh(ref)` polls a watched change request at once, including after a terminal state; if a fetch is already in flight for its forge, another fetch starts immediately after it settles. A failed read repeats after `activeIntervalMs` doubled for each consecutive failure, up to `maxBackoffMs`, which must be at least `activeIntervalMs`. Unregistering a provider settles its watched change requests as `unavailable`. Polling stops when the last watcher leaves. The [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-experimental-forge) lists the defaults.
Reaching `fetchTimeoutMs` aborts the fetch signal with `FORGE_FETCH_TIMEOUT`. Providers must stop their work and settle when the signal aborts; rejected fetches become `unavailable` failures. A provider that ignores cancellation can stall polling and service disposal.
+1 -1
View File
@@ -38,7 +38,7 @@ kind: "package-reference"
`diff(ref, signal)` 读取托管平台计算出的变更请求统一补丁,路径带 `a/` 与 `b/` 前缀;`fileText(ref, commit, path, signal)` 读取其仓库中某个提交下的一个文件。两者都委托给引用所属 forge 的提供方,没有注册提供方时抛错,resolve 为文本或 `ForgeFailure`;托管平台或提供方因结果超过其大小上限而拒绝时,失败为 `too-large`。`fileUrl(ref, commit, path)` 返回该文件在该提交下的网页地址。
`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭和已合并的变更请求不再轮询。`refresh(ref)` 立即轮询一个被监听的变更请求,已结束的也包括在内;该 forge 已有读取在进行时,等待其结束后立即再读一次。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`;该值必须大于或等于 `activeIntervalMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。
`ctx.forge.watch(ref, listener)` 同样是 effect。变更请求的第一个监听方会立即触发轮询;`latest(ref)` 返回最近一次结果。每个 forge 同时最多只有一次读取在进行,这次读取包含该 forge 所有到期的变更请求。打开状态的变更请求在检查未完成或可合并性未知时每 `activeIntervalMs` 轮询一次,否则每 `idleIntervalMs` 轮询一次。已关闭的变更请求按 `idleIntervalMs` 轮询以检测重新打开;已合并的停止自动轮询。`refresh(ref)` 立即轮询一个被监听的变更请求,已结束的也包括在内;该 forge 已有读取在进行时,等待其结束后立即再读一次。读取失败后,重试间隔为 `activeIntervalMs` 按连续失败次数翻倍,最长 `maxBackoffMs`;该值必须大于或等于 `activeIntervalMs`。注销提供方会让其被监听的变更请求以 `unavailable` 结束。最后一个监听方离开后轮询停止。默认值见[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-experimental-forge)。
达到 `fetchTimeoutMs` 时,读取信号会以 `FORGE_FETCH_TIMEOUT` 中止。提供方必须在信号中止时停止工作并结束请求;被拒绝的读取会转为 `unavailable` 失败。忽略取消的提供方可能阻塞轮询和服务销毁。
+5 -4
View File
@@ -6,7 +6,7 @@ import type { ChangeRequestSnapshot, ForgeFailure } from './types.ts'
export interface PollIntervals {
/** Delay while checks run or mergeability is unknown. */
readonly activeIntervalMs: number
/** Delay for an open change request whose checks have settled. */
/** Delay for a closed request or an open request whose checks have settled. */
readonly idleIntervalMs: number
/** Largest delay after consecutive failures. */
readonly maxBackoffMs: number
@@ -14,8 +14,8 @@ export interface PollIntervals {
/**
* Choose the delay before the next poll of one change request. Failures back
* off exponentially from the active interval; merged and closed change
* requests stop polling.
* off exponentially from the active interval; merged change requests stop
* polling, while closed ones use the idle cadence to detect reopening.
* @param result - latest snapshot or failure.
* @param failures - consecutive failures, counting `result` when it is one.
* @param intervals - configured cadence.
@@ -27,6 +27,7 @@ export function nextPollDelay(
intervals: PollIntervals,
): number | undefined {
if ('code' in result) return Math.min(intervals.maxBackoffMs, intervals.activeIntervalMs * 2 ** failures)
if (result.state !== 'open') return undefined
if (result.state === 'merged') return undefined
if (result.state === 'closed') return intervals.idleIntervalMs
return result.checksRollup === 'pending' || result.mergeability === 'unknown' ? intervals.activeIntervalMs : intervals.idleIntervalMs
}
+1 -1
View File
@@ -205,7 +205,7 @@ export type ChangeRequestListener = (update: ChangeRequestUpdate) => void
export interface Config {
/** Poll interval in milliseconds while checks run or mergeability is unknown. */
activeIntervalMs?: number
/** Poll interval in milliseconds for an open change request whose checks have settled. */
/** Poll interval in milliseconds for a closed request or an open request whose checks have settled. */
idleIntervalMs?: number
/** Largest delay in milliseconds between polls after consecutive failures. */
maxBackoffMs?: number
@@ -131,7 +131,7 @@ describe('forge watch', () => {
await vi.advanceTimersByTimeAsync(0)
expect(updates[0]).toEqual({ key, fetchedAt: 1_000_000, failure: { code: 'not-found', message: 'missing' } })
await vi.advanceTimersByTimeAsync(1_999)
await vi.advanceTimersByTimeAsync(config.activeIntervalMs * 2 - 1)
expect(provider.fetches).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(provider.fetches).toHaveLength(2)
@@ -189,7 +189,7 @@ describe('forge watch', () => {
it('refreshes a watched change request at once, including after a terminal state', async () => {
const provider = new FakeProvider()
provider.results.set(key, snapshot(ref(1), { state: 'closed' }))
provider.results.set(key, snapshot(ref(1), { state: 'merged' }))
const context = await setup(provider)
collect(context)
await vi.advanceTimersByTimeAsync(60_000)
@@ -396,4 +396,19 @@ describe('forge watch', () => {
expect(provider.fetches).toHaveLength(1)
})
it('polls closed requests at the idle cadence and publishes reopening', async () => {
const provider = new FakeProvider()
provider.results.set(key, snapshot(ref(1), { state: 'closed' }))
const context = await setup(provider)
const { updates } = collect(context)
await vi.advanceTimersByTimeAsync(0)
expect(updates.at(-1)).toMatchObject({ snapshot: { state: 'closed' } })
provider.results.set(key, snapshot(ref(1), { state: 'open' }))
await vi.advanceTimersByTimeAsync(config.idleIntervalMs - 1)
expect(provider.fetches).toHaveLength(1)
await vi.advanceTimersByTimeAsync(1)
expect(updates.at(-1)).toMatchObject({ snapshot: { state: 'open' } })
expect(provider.fetches).toHaveLength(2)
})
})
@@ -4574,6 +4574,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
declaredBy: 'an entry in \'sidebar.workspaces\' (client-ui-workspace), so it exists while that entry is mounted',
occupants: [
'client-ui-schedule SessionScheduleHover id \'schedule-tasks\'',
'experimental-client-ui-forge SessionHoverForge id \'forge-panel.row\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.hover\', () => ctx.slots.register(\n { name: \'sidebar.session.row.hover\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
@@ -4680,7 +4681,9 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
hookContext: '',
slotInject: '',
declaredBy: 'an entry in \'sidebar.workspaces\' (client-ui-workspace), so it exists while that entry is mounted',
occupants: [],
occupants: [
'experimental-client-ui-forge SessionRowForge id \'forge-panel.row\'',
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'sidebar.session.row.trailing\', () => ctx.slots.register(\n { name: \'sidebar.session.row.trailing\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
source: 'packages/client/ui-workspace/src/client/contract/slots.ts:146',
@@ -1328,6 +1328,13 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'session', description: 'Session to follow.' }, { name: 'signal', description: 'ends the stream.' }],
returns: 'panel states in publication order.',
},
{
signature: '@Remote({ mode: \'stream\' }) async *changeRequestStates(signal: AbortSignal): AsyncIterable<ForgeChangeRequestStatesFrame>',
description: 'Stream the last known state of every change request that a listed Session the caller may read binds: the stored states first, then each state a poll changes or a new binding reveals. Listing reads no Session log: loaded Sessions report their bindings, others the cached Session summary, and a Session without one shows nothing until it is opened. Closed requests are polled at the idle cadence so reopening is observed. The stream ends when `signal` aborts or the Remote stops.',
parameters: [{ name: 'signal', description: 'ends the stream.' }],
returns: 'frames in publication order.',
throws: ['when the Host lacks the Session query, projection cache, storage, or Session store service.'],
},
{
signature: '@Remote async branches(session: Session, signal: AbortSignal): Promise<ForgeBranches>',
description: 'List the local branches that can start a worktree of the Session\'s current checkout.',
@@ -6185,6 +6192,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'ForgeChangeRequestDiff',
declaration: 'export interface ForgeChangeRequestDiff {\n readonly headSha: string;\n readonly files: readonly ForgeDiffFile[];\n readonly localAhead: number | null;\n readonly fileUrls: Readonly<Record<string, string>>;\n}',
},
{
name: 'ForgeChangeRequestListState',
declaration: 'export type ForgeChangeRequestListState = Pick<ChangeRequestSnapshot, \'url\' | \'title\' | \'state\' | \'draft\' | \'checksRollup\' | \'reviewDecision\' | \'mergeability\'> & {\n readonly number: number;\n};',
},
{
name: 'ForgeChangeRequestStatesFrame',
declaration: 'export type ForgeChangeRequestStatesFrame = {\n readonly type: \'baseline\';\n readonly states: readonly ForgeChangeRequestListState[];\n} | {\n readonly type: \'changed\';\n readonly states: readonly ForgeChangeRequestListState[];\n};',
},
{
name: 'ForgeCheckout',
declaration: 'export interface ForgeCheckout {\n readonly root: string;\n readonly branch: string | null;\n readonly linked: boolean;\n readonly commit: string | null;\n}',
+12
View File
@@ -8988,6 +8988,9 @@ importers:
packages/experimental/client-ui-forge:
dependencies:
'@deepseek-ai/dsh-storage-domain':
specifier: workspace:*
version: link:../../storage/storage-domain
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
@@ -9013,6 +9016,9 @@ importers:
'@deepseek-ai/dsh-agent-loop-testkit':
specifier: workspace:*
version: link:../../test-support/agent-loop-testkit
'@deepseek-ai/dsh-api-remote-access':
specifier: workspace:*
version: link:../../api/remote-access
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:*
version: link:../../api/remotes
@@ -9085,6 +9091,12 @@ importers:
'@deepseek-ai/dsh-session-projection':
specifier: workspace:*
version: link:../../session/session-projection
'@deepseek-ai/dsh-session-projection-cache':
specifier: workspace:*
version: link:../../session/session-projection-cache
'@deepseek-ai/dsh-session-query':
specifier: workspace:*
version: link:../../session-query/session-query
'@deepseek-ai/dsh-subprocess':
specifier: workspace:*
version: link:../../subprocess/subprocess
+1
View File
@@ -377,6 +377,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
ForgeActionFailure: 'forge.md',
ForgeBranches: 'forge.md',
ForgeChangeRequestDiff: 'forge.md',
ForgeChangeRequestStatesFrame: 'forge.md',
ForgeFailure: 'forge.md',
ForgeLocalDiff: 'forge.md',
ForgeLocalScope: 'forge.md',
+10
View File
@@ -2802,6 +2802,16 @@
"symbol": "ForgeBranches",
"source": "packages/experimental/client-ui-forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ForgeChangeRequestListState",
"source": "packages/experimental/client-ui-forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ForgeChangeRequestStatesFrame",
"source": "packages/experimental/client-ui-forge/src/types.ts"
},
{
"doc": "docs/subsystems/forge.md",
"symbol": "ForgeLocalScope",